跳转至

原生代码与 Electron:C++(Linux)

本教程基于 原生代码与 Electron 的通用介绍,并专注于使用 C++ 和 GTK3 为 Linux 创建原生插件。为了说明如何在 Electron 应用中嵌入原生 Linux 代码,我们将构建一个基本的原生 GTK3 GUI,使其能够与 Electron 的 JavaScript 进行通信。

具体来说,我们将使用 GTK3 作为 GUI 界面,它提供:

  • 一套全面的 UI 控件,例如按钮、输入框和列表
  • 跨各种 Linux 发行版的桌面环境兼容性
  • 与 Linux 桌面原生主题和辅助功能特性的集成

[!NOTE] 我们特意使用 GTK3,因为 Chromium(以及由此衍生的 Electron)内部使用的就是 GTK3。如果使用 GTK4,会导致运行时冲突,因为 GTK3 和 GTK4 都会被加载到同一进程中。如果 Chromium 将来升级到 GTK4,你很可能也能轻松地将原生代码升级到 GTK4。

本教程对已经熟悉 Linux 上 GTK 开发的人最有用。你应该具备基本的 GTK 概念经验,例如控件、信号和主事件循环。为了简洁起见,我们不会花太多时间解释所使用的各个 GTK 元素或为它们编写的代码。这使得本教程对已经了解 GTK 开发并希望将这些技能用于 Electron 的人真正有帮助——而不必成为一本完整的 GTK 文档。

[!NOTE] 如果你还不熟悉这些概念,GTK3 文档 和 GTK3 教程 是不错的入门资源。GNOME 开发者文档 也提供了全面的 GTK 开发指南。

Requirements

就像我们在原生代码与 Electron 的通用介绍中一样,本教程假设你已安装 Node.js 和 npm,以及编译原生代码所需的基本工具。由于本教程讨论编写与 GTK3 交互的原生代码,你需要:

  • 一个已安装 GTK3 开发文件的 Linux 发行版
  • pkg-config 工具
  • G++ 编译器和构建工具

在 Ubuntu/Debian 上,你可以使用以下命令安装这些依赖:

sudo apt-get install build-essential pkg-config libgtk-3-dev

在 Fedora/RHEL/CentOS 上:

sudo dnf install gcc-c++ pkgconfig gtk3-devel

1) 创建包

你可以复用我们在 原生代码与 Electron 教程中创建的包。本教程不会重复其中描述的步骤。首先,让我们设置基本插件文件夹结构:

cpp-linux/
├── binding.gyp          # Configuration file for node-gyp to build the native addon
├── include/
│   └── cpp_code.h       # Header file with declarations for our C++ native code
├── js/
│   └── index.js         # JavaScript interface that loads and exposes our native addon
├── package.json         # Node.js package configuration and dependencies
└── src/
    ├── cpp_addon.cc     # C++ code that bridges Node.js/Electron with our native code
    └── cpp_code.cc      # Implementation of our native C++ functionality using GTK3

我们的 package.json 应该如下所示:

package.json
{
  "name": "cpp-linux",
  "version": "1.0.0",
  "description": "A demo module that exposes C++ code to Electron",
  "main": "js/index.js",
  "scripts": {
    "clean": "rm -rf build",
    "build-electron": "electron-rebuild",
    "build": "node-gyp configure && node-gyp build"
  },
  "license": "MIT",
  "dependencies": {
    "node-addon-api": "^8.3.0",
    "bindings": "^1.5.0"
  }
}

2) 设置构建配置

对于使用 GTK3 的 Linux 专用插件,我们需要正确配置 binding.gyp 文件,以确保该插件仅在 Linux 系统上编译——理想情况下在其他平台上不执行任何操作。这包括使用条件编译标志,利用 pkg-config 自动定位并包含用户系统上的 GTK3 库和头文件路径,并设置适当的编译器标志以启用异常处理和线程支持等功能。该配置将确保我们的原生代码能够正确对接 Node.js/Electron 运行时以及提供原生 GUI 功能的 GTK3 库。

binding.gyp
{
  "targets": [
    {
      "target_name": "cpp_addon",
      "conditions": [
        ['OS=="linux"', {
          "sources": [
            "src/cpp_addon.cc",
            "src/cpp_code.cc"
          ],
          "include_dirs": [
            "<!@(node -p \"require('node-addon-api').include\")",
            "include",
            "<!@(pkg-config --cflags-only-I gtk+-3.0 | sed s/-I//g)"
          ],
          "libraries": [
            "<!@(pkg-config --libs gtk+-3.0)",
            "-luuid"
          ],
          "cflags": [
            "-fexceptions",
            "<!@(pkg-config --cflags gtk+-3.0)",
            "-pthread"
          ],
          "cflags_cc": [
            "-fexceptions",
            "<!@(pkg-config --cflags gtk+-3.0)",
            "-pthread"
          ],
          "ldflags": [
            "-pthread"
          ],
          "cflags!": ["-fno-exceptions"],
          "cflags_cc!": ["-fno-exceptions"],
          "defines": ["NODE_ADDON_API_CPP_EXCEPTIONS"],
          "dependencies": [
            "<!(node -p \"require('node-addon-api').gyp\")"
          ]
        }]
      ]
    }
  ]
}

让我们检查此配置的关键部分,从 pkg-config 集成开始。binding.gyp 文件中的 <!@ 语法是命令展开运算符。它会执行括号内的命令,并将该命令的输出用作该位置的值。因此,只要看到包含 pkg-config 的 <!@,就知道我们正在调用一个 pkg-config 命令,并将其输出用作值。sed 命令会移除包含路径中的 -I 前缀,使其与 GYP 格式兼容。

3) 定义 C++ 接口

让我们在 include/cpp_code.h 中定义我们的头文件:

include/cpp_code.h
#pragma once
#include <string>
#include <functional>

namespace cpp_code {

std::string hello_world(const std::string& input);
void hello_gui();

// Callback function types
using TodoCallback = std::function<void(const std::string&)>;

// Callback setters
void setTodoAddedCallback(TodoCallback callback);
void setTodoUpdatedCallback(TodoCallback callback);
void setTodoDeletedCallback(TodoCallback callback);

} // namespace cpp_code

该头文件定义了:

  • 一个基本的 hello_world 函数
  • 一个用于创建 GTK3 GUI 的 hello_gui 函数
  • 用于 Todo 操作(添加、更新、删除)的回调类型
  • 用于设置回调的 setter 函数

4) 实现 GTK3 GUI 代码

现在,让我们在 src/cpp_code.cc 中实现我们的 GTK3 GUI。我们会将其拆分为易于管理的部分。我们将从一些 include 以及基本设置开始。

基本设置和数据结构

src/cpp_code.cc
#include <gtk/gtk.h>
#include <string>
#include <functional>
#include <chrono>
#include <vector>
#include <uuid/uuid.h>
#include <ctime>
#include <thread>
#include <memory>

using TodoCallback = std::function<void(const std::string &)>;

namespace cpp_code
{
  // Basic functions
  std::string hello_world(const std::string &input)
  {
    return "Hello from C++! You said: " + input;
  }

  // Data structures
  struct TodoItem
  {
    uuid_t id;
    std::string text;
    int64_t date;

    std::string toJson() const
    {
      char uuid_str[37];
      uuid_unparse(id, uuid_str);
      return "{"
             "\"id\":\"" +
             std::string(uuid_str) + "\","
                                     "\"text\":\"" +
             text + "\","
                    "\"date\":" +
             std::to_string(date) +
             "}";
    }

    static std::string formatDate(int64_t timestamp)
    {
      char date_str[64];
      time_t unix_time = timestamp / 1000;
      strftime(date_str, sizeof(date_str), "%Y-%m-%d", localtime(&unix_time));
      return date_str;
    }
  };

在这一部分中:

  • 我们包含 GTK3、标准库组件和 UUID 生成所需的头文件。
  • 定义 TodoCallback 类型,用于处理向 JavaScript 的通信。
  • 创建一个 TodoItem 结构体,用于存储我们的 todo 数据,其中包含:
  • 用于唯一标识的 UUID
  • 文本内容和时间戳
  • 一个用于转换为 JSON 并发送给 JavaScript 的方法
  • 一个用于格式化日期以供显示的静态辅助函数

toJson() 方法尤其重要,因为它使我们的 C++ 对象能够被序列化并传输到 JavaScript。可能还有更好的做法,但本教程的主题是将 C++ 用于原生 Linux UI 开发并与 Electron 结合,因此我们在这里不写更好的 JSON 序列化代码也是可以接受的。C++ 中有很多用于处理 JSON 的库,它们各有不同的取舍。参见 https://www.json.org/json-en.html 获取列表。

值得注意的是,我们实际上还没有添加任何用户界面——我们将在下一步中完成。GTK 代码往往比较冗长,所以请耐心阅读——尽管篇幅较长。

全局状态和前置声明

在你 src/cpp_code.cc 中已有的代码下方,添加以下内容:

src/cpp_code.cc
  // Forward declarations
  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo);
  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo);

  // Global state
  namespace
  {
    TodoCallback g_todoAddedCallback;
    TodoCallback g_todoUpdatedCallback;
    TodoCallback g_todoDeletedCallback;
    GMainContext *g_gtk_main_context = nullptr;
    GMainLoop *g_main_loop = nullptr;
    std::thread *g_gtk_thread = nullptr;
    std::vector<TodoItem> g_todos;
  }

在这里我们:

  • 前置声明稍后会使用的辅助函数
  • 在一个匿名命名空间中设置全局状态,包括:
  • 用于 add、update 和 delete todo 操作的回调
  • 用于线程管理的 GTK 主上下文和主循环指针
  • 指向 GTK 线程本身的指针
  • 一个用于存储我们 todos 的 vector

这些全局变量用于跟踪应用程序状态,并允许我们代码的不同部分相互交互。线程管理变量(g_gtk_main_context、g_main_loop 和 g_gtk_thread)尤其重要,因为 GTK 需要在自己的事件循环中运行。由于我们的代码将从 Node.js/Electron 的主线程调用,我们需要在单独的线程中运行 GTK,以避免阻塞 JavaScript 事件循环。这种分离确保我们的原生 UI 保持响应,同时仍允许与 Electron 应用程序进行双向通信。这些回调使我们在用户与我们的原生 GTK 界面交互时,能够将事件发送回 JavaScript。

辅助函数

继续,我们在已经编写的代码下方添加更多代码。在这一部分中,我们添加三个静态辅助方法——同时也开始设置一些真正的原生用户界面。我们将添加一个以线程安全方式通知回调的辅助函数、一个用于更新行标签的函数,以及一个用于创建整个“Add Todo”对话框的函数。

src/cpp_code.cc
  // Helper functions
  static void notify_callback(const TodoCallback &callback, const std::string &json)
  {
    if (callback && g_gtk_main_context)
    {
      g_main_context_invoke(g_gtk_main_context, [](gpointer data) -> gboolean
                            {
            auto* cb_data = static_cast<std::pair<TodoCallback, std::string>*>(data);
            cb_data->first(cb_data->second);
            delete cb_data;
            return G_SOURCE_REMOVE; }, new std::pair<TodoCallback, std::string>(callback, json));
    }
  }

  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo)
  {
    auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
    auto *old_label = GTK_WIDGET(gtk_container_get_children(GTK_CONTAINER(row))->data);
    gtk_container_remove(GTK_CONTAINER(row), old_label);
    gtk_container_add(GTK_CONTAINER(row), label);
    gtk_widget_show_all(GTK_WIDGET(row));
  }

  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo = nullptr)
  {
    auto *dialog = gtk_dialog_new_with_buttons(
        existing_todo ? "Edit Todo" : "Add Todo",
        parent,
        GTK_DIALOG_MODAL,
        "_Cancel", GTK_RESPONSE_CANCEL,
        "_Save", GTK_RESPONSE_ACCEPT,
        nullptr);

    auto *content_area = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
    gtk_container_set_border_width(GTK_CONTAINER(content_area), 10);

    auto *entry = gtk_entry_new();
    if (existing_todo)
    {
      gtk_entry_set_text(GTK_ENTRY(entry), existing_todo->text.c_str());
    }
    gtk_container_add(GTK_CONTAINER(content_area), entry);

    auto *calendar = gtk_calendar_new();
    if (existing_todo)
    {
      time_t unix_time = existing_todo->date / 1000;
      struct tm *timeinfo = localtime(&unix_time);
      gtk_calendar_select_month(GTK_CALENDAR(calendar), timeinfo->tm_mon, timeinfo->tm_year + 1900);
      gtk_calendar_select_day(GTK_CALENDAR(calendar), timeinfo->tm_mday);
    }
    gtk_container_add(GTK_CONTAINER(content_area), calendar);

    gtk_widget_show_all(dialog);
    return dialog;
  }

这些辅助函数对我们的应用程序至关重要:

  • notify_callback:使用 g_main_context_invoke 从 GTK 线程安全地调用 JavaScript 回调,该函数会将函数执行调度到 GTK 主上下文中。提醒一下,GTK 主上下文是必须执行 GTK 操作以确保线程安全的环境,因为 GTK 不是线程安全的,所有 UI 操作都必须发生在主线程上。
  • update_todo_row_label:使用新文本和格式化后的日期更新待办事项列表中的一行。
  • create_todo_dialog:创建一个用于添加或编辑待办事项的对话框,包含:
  • 用于输入待办事项文本的文本输入字段
  • 用于选择日期的日历控件
  • 用于保存或取消的相应按钮

事件处理程序

我们的原生用户界面具有事件——并且这些事件必须被处理。这段代码中唯一与 Electron 相关的部分是,我们正在通知我们的 JS 回调。

src/cpp_code.cc
  static void edit_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    auto *dialog = create_todo_dialog(
        GTK_WINDOW(gtk_builder_get_object(builder, "window")),
        &g_todos[index]);

    if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT)
    {
      auto *entry = GTK_ENTRY(gtk_container_get_children(
                                  GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                  ->data);
      auto *calendar = GTK_CALENDAR(gtk_container_get_children(
                                        GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                        ->next->data);

      const char *new_text = gtk_entry_get_text(entry);

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      gint64 new_date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos[index].text = new_text;
      g_todos[index].date = new_date;

      update_todo_row_label(row, g_todos[index]);
      notify_callback(g_todoUpdatedCallback, g_todos[index].toJson());
    }

    gtk_widget_destroy(dialog);
  }

  static void delete_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    std::string json = g_todos[index].toJson();
    gtk_container_remove(GTK_CONTAINER(list), GTK_WIDGET(row));
    g_todos.erase(g_todos.begin() + index);
    notify_callback(g_todoDeletedCallback, json);
  }

  static void on_add_clicked(GtkButton *button, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *entry = GTK_ENTRY(gtk_builder_get_object(builder, "todo_entry"));
    auto *calendar = GTK_CALENDAR(gtk_builder_get_object(builder, "todo_calendar"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    const char *text = gtk_entry_get_text(entry);
    if (strlen(text) > 0)
    {
      TodoItem todo;
      uuid_generate(todo.id);
      todo.text = text;

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      todo.date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos.push_back(todo);

      auto *row = gtk_list_box_row_new();
      auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
      gtk_container_add(GTK_CONTAINER(row), label);
      gtk_container_add(GTK_CONTAINER(list), row);
      gtk_widget_show_all(row);

      gtk_entry_set_text(entry, "");

      notify_callback(g_todoAddedCallback, todo.toJson());
    }
  }

  static void on_row_activated(GtkListBox *list_box, GtkListBoxRow *row, gpointer user_data)
  {
    GMenu *menu = g_menu_new();
    g_menu_append(menu, "Edit", "app.edit");
    g_menu_append(menu, "Delete", "app.delete");

    auto *popover = gtk_popover_new_from_model(GTK_WIDGET(row), G_MENU_MODEL(menu));
    gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_RIGHT);
    gtk_popover_popup(GTK_POPOVER(popover));

    g_object_unref(menu);
  }

这些事件处理程序管理用户交互:

edit_action:通过以下方式处理编辑待办事项:

  • 获取选中的行
  • 使用当前待办事项数据创建对话框
  • 如果用户确认,则更新待办事项
  • 通过回调通知 JavaScript

delete_action:删除一个待办事项并通知 JavaScript。

on_add_clicked:当用户点击 Add 按钮时添加一个新的待办事项:

  • 从输入字段获取文本和日期
  • 创建一个具有唯一 ID 的新 TodoItem
  • 将其添加到列表和底层数据存储中
  • 通知 JavaScript

on_row_activated:当点击一个待办事项时显示弹出菜单,提供编辑或删除选项。

GTK 应用设置

现在,我们需要设置我们的 GTK 应用程序。考虑到我们已经有一个正在运行的 GTK 应用程序,这可能会让人反直觉。这里的激活代码是必要的,因为这是与 Electron 一起运行的原生 C++ 代码,而不是在 Electron 内部运行。虽然 Electron 确实拥有自己的主进程和渲染进程,但这个 GTK 应用程序作为一个原生操作系统窗口运行,它从 Electron 应用程序启动,但在自己的进程或线程中运行。hello_gui() 函数专门启动 GTK 应用程序,拥有自己的线程(g_gtk_thread)、应用程序循环和 UI 上下文。

src/cpp_code.cc
  static gboolean init_gtk_app(gpointer user_data)
  {
    auto *app = static_cast<GtkApplication *>(user_data);
    g_application_run(G_APPLICATION(app), 0, nullptr);
    g_object_unref(app);
    if (g_main_loop)
    {
      g_main_loop_quit(g_main_loop);
    }
    return G_SOURCE_REMOVE;
  }

  static void activate_handler(GtkApplication *app, gpointer user_data)
  {
    auto *builder = gtk_builder_new();

    const GActionEntry app_actions[] = {
        {"edit", edit_action, nullptr, nullptr, nullptr, {0, 0, 0}},
        {"delete", delete_action, nullptr, nullptr, nullptr, {0, 0, 0}}};
    g_action_map_add_action_entries(G_ACTION_MAP(app), app_actions,
                                    G_N_ELEMENTS(app_actions), builder);

    gtk_builder_add_from_string(builder,
                                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
                                "<interface>"
                                "  <object class=\"GtkWindow\" id=\"window\">"
                                "    <property name=\"title\">Todo List</property>"
                                "    <property name=\"default-width\">400</property>"
                                "    <property name=\"default-height\">500</property>"
                                "    <child>"
                                "      <object class=\"GtkBox\">"
                                "        <property name=\"visible\">true</property>"
                                "        <property name=\"orientation\">vertical</property>"
                                "        <property name=\"spacing\">6</property>"
                                "        <property name=\"margin\">12</property>"
                                "        <child>"
                                "          <object class=\"GtkBox\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"spacing\">6</property>"
                                "            <child>"
                                "              <object class=\"GtkEntry\" id=\"todo_entry\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"hexpand\">true</property>"
                                "                <property name=\"placeholder-text\">Enter todo item...</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkCalendar\" id=\"todo_calendar\">"
                                "                <property name=\"visible\">true</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkButton\" id=\"add_button\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"label\">Add</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "        <child>"
                                "          <object class=\"GtkScrolledWindow\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"vexpand\">true</property>"
                                "            <child>"
                                "              <object class=\"GtkListBox\" id=\"todo_list\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"selection-mode\">single</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "      </object>"
                                "    </child>"
                                "  </object>"
                                "</interface>",
                                -1, nullptr);

    auto *window = GTK_WINDOW(gtk_builder_get_object(builder, "window"));
    auto *button = GTK_BUTTON(gtk_builder_get_object(builder, "add_button"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    gtk_window_set_application(window, app);

    g_signal_connect(button, "clicked", G_CALLBACK(on_add_clicked), builder);
    g_signal_connect(list, "row-activated", G_CALLBACK(on_row_activated), nullptr);

    gtk_widget_show_all(GTK_WIDGET(window));
  }

让我们更仔细地看看上面的代码:

  • init_gtk_app:运行 GTK 应用程序主循环。
  • activate_handler:在激活时设置应用程序 UI:
  • 创建一个 GtkBuilder 用于加载 UI
  • 注册 edit 和 delete 操作
  • 使用 GTK 的 XML 标记语言定义 UI 布局
  • 将信号连接到我们的事件处理程序

UI 布局使用 XML 内联定义,这是 GTK 应用程序中常见的模式。它创建一个主窗口、输入控件(文本输入框、日历和添加按钮)、用于显示待办事项的列表框,以及适当的布局容器和滚动支持。

主 GUI 函数和线程管理

现在我们已经把所有内容都连接好了,可以添加两个核心 GUI 函数:hello_gui()(我们将从 JavaScript 中调用它)和 cleanup_gui(),用于清理所有内容。你可能会很高兴地发现,我们对 GTK 应用程序、上下文和线程的仔细设置使这一步变得简单明了:

src/cpp_code.cc
  void hello_gui()
  {
    if (g_gtk_thread != nullptr)
    {
      g_print("GTK application is already running.\n");
      return;
    }

    if (!gtk_init_check(0, nullptr))
    {
      g_print("Failed to initialize GTK.\n");
      return;
    }

    g_gtk_main_context = g_main_context_new();
    g_main_loop = g_main_loop_new(g_gtk_main_context, FALSE);

    g_gtk_thread = new std::thread([]()
                                   {
        GtkApplication* app = gtk_application_new("com.example.todo", G_APPLICATION_NON_UNIQUE);
        g_signal_connect(app, "activate", G_CALLBACK(activate_handler), nullptr);

        g_idle_add_full(G_PRIORITY_DEFAULT, init_gtk_app, app, nullptr);

        if (g_main_loop) {
            g_main_loop_run(g_main_loop);
        } });

    g_gtk_thread->detach();
  }

  void cleanup_gui()
  {
    if (g_main_loop && g_main_loop_is_running(g_main_loop))
    {
      g_main_loop_quit(g_main_loop);
    }

    if (g_main_loop)
    {
      g_main_loop_unref(g_main_loop);
      g_main_loop = nullptr;
    }

    if (g_gtk_main_context)
    {
      g_main_context_unref(g_gtk_main_context);
      g_gtk_main_context = nullptr;
    }

    g_gtk_thread = nullptr;
  }

这些函数管理 GTK 应用程序的生命周期:

  • hello_gui:暴露给 JavaScript 的入口点,用于检查 GTK 是否已在运行,初始化 GTK,创建新的主上下文和主循环,启动一个线程来运行 GTK 应用程序,并分离该线程,使其独立运行。
  • cleanup_gui:在应用程序关闭时正确清理 GTK 资源。

在单独的线程中运行 GTK 对于 Electron 集成至关重要,因为它可以防止 GTK 主循环阻塞 Node.js 的事件循环。

回调管理

之前,我们设置了全局变量来保存回调。现在,我们将添加用于分配这些回调的函数。这些回调构成了原生 GTK 代码与 JavaScript 之间的桥梁,允许双向通信。

src/cpp_code.cc
  void setTodoAddedCallback(TodoCallback callback)
  {
    g_todoAddedCallback = callback;
  }

  void setTodoUpdatedCallback(TodoCallback callback)
  {
    g_todoUpdatedCallback = callback;
  }

  void setTodoDeletedCallback(TodoCallback callback)
  {
    g_todoDeletedCallback = callback;
  }

将 cpp_code.cc 组合在一起

我们现在已经完成了插件的 GTK 和原生部分——也就是说,这部分代码最关注与操作系统交互(相比之下,较少关注桥接原生 C++ 和 JavaScript 世界)。在添加上述所有部分之后,你的 src/cpp_code.cc 应该如下所示:

src/cpp_code.cc
#include <gtk/gtk.h>
#include <string>
#include <functional>
#include <chrono>
#include <vector>
#include <uuid/uuid.h>
#include <ctime>
#include <thread>
#include <memory>

using TodoCallback = std::function<void(const std::string &)>;

namespace cpp_code
{

  // Basic functions
  std::string hello_world(const std::string &input)
  {
    return "Hello from C++! You said: " + input;
  }

  // Data structures
  struct TodoItem
  {
    uuid_t id;
    std::string text;
    int64_t date;

    std::string toJson() const
    {
      char uuid_str[37];
      uuid_unparse(id, uuid_str);
      return "{"
             "\"id\":\"" +
             std::string(uuid_str) + "\","
                                     "\"text\":\"" +
             text + "\","
                    "\"date\":" +
             std::to_string(date) +
             "}";
    }

    static std::string formatDate(int64_t timestamp)
    {
      char date_str[64];
      time_t unix_time = timestamp / 1000;
      strftime(date_str, sizeof(date_str), "%Y-%m-%d", localtime(&unix_time));
      return date_str;
    }
  };

  // Forward declarations
  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo);
  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo);

  // Global state
  namespace
  {
    TodoCallback g_todoAddedCallback;
    TodoCallback g_todoUpdatedCallback;
    TodoCallback g_todoDeletedCallback;
    GMainContext *g_gtk_main_context = nullptr;
    GMainLoop *g_main_loop = nullptr;
    std::thread *g_gtk_thread = nullptr;
    std::vector<TodoItem> g_todos;
  }

  // Helper functions
  static void notify_callback(const TodoCallback &callback, const std::string &json)
  {
    if (callback && g_gtk_main_context)
    {
      g_main_context_invoke(g_gtk_main_context, [](gpointer data) -> gboolean
                            {
            auto* cb_data = static_cast<std::pair<TodoCallback, std::string>*>(data);
            cb_data->first(cb_data->second);
            delete cb_data;
            return G_SOURCE_REMOVE; }, new std::pair<TodoCallback, std::string>(callback, json));
    }
  }

  static void update_todo_row_label(GtkListBoxRow *row, const TodoItem &todo)
  {
    auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
    auto *old_label = GTK_WIDGET(gtk_container_get_children(GTK_CONTAINER(row))->data);
    gtk_container_remove(GTK_CONTAINER(row), old_label);
    gtk_container_add(GTK_CONTAINER(row), label);
    gtk_widget_show_all(GTK_WIDGET(row));
  }

  static GtkWidget *create_todo_dialog(GtkWindow *parent, const TodoItem *existing_todo = nullptr)
  {
    auto *dialog = gtk_dialog_new_with_buttons(
        existing_todo ? "Edit Todo" : "Add Todo",
        parent,
        GTK_DIALOG_MODAL,
        "_Cancel", GTK_RESPONSE_CANCEL,
        "_Save", GTK_RESPONSE_ACCEPT,
        nullptr);

    auto *content_area = gtk_dialog_get_content_area(GTK_DIALOG(dialog));
    gtk_container_set_border_width(GTK_CONTAINER(content_area), 10);

    auto *entry = gtk_entry_new();
    if (existing_todo)
    {
      gtk_entry_set_text(GTK_ENTRY(entry), existing_todo->text.c_str());
    }
    gtk_container_add(GTK_CONTAINER(content_area), entry);

    auto *calendar = gtk_calendar_new();
    if (existing_todo)
    {
      time_t unix_time = existing_todo->date / 1000;
      struct tm *timeinfo = localtime(&unix_time);
      gtk_calendar_select_month(GTK_CALENDAR(calendar), timeinfo->tm_mon, timeinfo->tm_year + 1900);
      gtk_calendar_select_day(GTK_CALENDAR(calendar), timeinfo->tm_mday);
    }
    gtk_container_add(GTK_CONTAINER(content_area), calendar);

    gtk_widget_show_all(dialog);
    return dialog;
  }

  static void edit_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    auto *dialog = create_todo_dialog(
        GTK_WINDOW(gtk_builder_get_object(builder, "window")),
        &g_todos[index]);

    if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT)
    {
      auto *entry = GTK_ENTRY(gtk_container_get_children(
                                  GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                  ->data);
      auto *calendar = GTK_CALENDAR(gtk_container_get_children(
                                        GTK_CONTAINER(gtk_dialog_get_content_area(GTK_DIALOG(dialog))))
                                        ->next->data);

      const char *new_text = gtk_entry_get_text(entry);

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      gint64 new_date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos[index].text = new_text;
      g_todos[index].date = new_date;

      update_todo_row_label(row, g_todos[index]);
      notify_callback(g_todoUpdatedCallback, g_todos[index].toJson());
    }

    gtk_widget_destroy(dialog);
  }

  static void delete_action(GSimpleAction *action, GVariant *parameter, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));
    auto *row = gtk_list_box_get_selected_row(list);
    if (!row)
      return;

    gint index = gtk_list_box_row_get_index(row);
    auto size = static_cast<gint>(g_todos.size());
    if (index < 0 || index >= size)
      return;

    std::string json = g_todos[index].toJson();
    gtk_container_remove(GTK_CONTAINER(list), GTK_WIDGET(row));
    g_todos.erase(g_todos.begin() + index);
    notify_callback(g_todoDeletedCallback, json);
  }

  static void on_add_clicked(GtkButton *button, gpointer user_data)
  {
    auto *builder = static_cast<GtkBuilder *>(user_data);
    auto *entry = GTK_ENTRY(gtk_builder_get_object(builder, "todo_entry"));
    auto *calendar = GTK_CALENDAR(gtk_builder_get_object(builder, "todo_calendar"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    const char *text = gtk_entry_get_text(entry);
    if (strlen(text) > 0)
    {
      TodoItem todo;
      uuid_generate(todo.id);
      todo.text = text;

      guint year, month, day;
      gtk_calendar_get_date(calendar, &year, &month, &day);
      GDateTime *datetime = g_date_time_new_local(year, month + 1, day, 0, 0, 0);
      todo.date = g_date_time_to_unix(datetime) * 1000;
      g_date_time_unref(datetime);

      g_todos.push_back(todo);

      auto *row = gtk_list_box_row_new();
      auto *label = gtk_label_new((todo.text + " - " + TodoItem::formatDate(todo.date)).c_str());
      gtk_container_add(GTK_CONTAINER(row), label);
      gtk_container_add(GTK_CONTAINER(list), row);
      gtk_widget_show_all(row);

      gtk_entry_set_text(entry, "");

      notify_callback(g_todoAddedCallback, todo.toJson());
    }
  }

  static void on_row_activated(GtkListBox *list_box, GtkListBoxRow *row, gpointer user_data)
  {
    GMenu *menu = g_menu_new();
    g_menu_append(menu, "Edit", "app.edit");
    g_menu_append(menu, "Delete", "app.delete");

    auto *popover = gtk_popover_new_from_model(GTK_WIDGET(row), G_MENU_MODEL(menu));
    gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_RIGHT);
    gtk_popover_popup(GTK_POPOVER(popover));

    g_object_unref(menu);
  }

  static gboolean init_gtk_app(gpointer user_data)
  {
    auto *app = static_cast<GtkApplication *>(user_data);
    g_application_run(G_APPLICATION(app), 0, nullptr);
    g_object_unref(app);
    if (g_main_loop)
    {
      g_main_loop_quit(g_main_loop);
    }
    return G_SOURCE_REMOVE;
  }

  static void activate_handler(GtkApplication *app, gpointer user_data)
  {
    auto *builder = gtk_builder_new();

    const GActionEntry app_actions[] = {
        {"edit", edit_action, nullptr, nullptr, nullptr, {0, 0, 0}},
        {"delete", delete_action, nullptr, nullptr, nullptr, {0, 0, 0}}};
    g_action_map_add_action_entries(G_ACTION_MAP(app), app_actions,
                                    G_N_ELEMENTS(app_actions), builder);

    gtk_builder_add_from_string(builder,
                                "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
                                "<interface>"
                                "  <object class=\"GtkWindow\" id=\"window\">"
                                "    <property name=\"title\">Todo List</property>"
                                "    <property name=\"default-width\">400</property>"
                                "    <property name=\"default-height\">500</property>"
                                "    <child>"
                                "      <object class=\"GtkBox\">"
                                "        <property name=\"visible\">true</property>"
                                "        <property name=\"orientation\">vertical</property>"
                                "        <property name=\"spacing\">6</property>"
                                "        <property name=\"margin\">12</property>"
                                "        <child>"
                                "          <object class=\"GtkBox\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"spacing\">6</property>"
                                "            <child>"
                                "              <object class=\"GtkEntry\" id=\"todo_entry\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"hexpand\">true</property>"
                                "                <property name=\"placeholder-text\">Enter todo item...</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkCalendar\" id=\"todo_calendar\">"
                                "                <property name=\"visible\">true</property>"
                                "              </object>"
                                "            </child>"
                                "            <child>"
                                "              <object class=\"GtkButton\" id=\"add_button\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"label\">Add</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "        <child>"
                                "          <object class=\"GtkScrolledWindow\">"
                                "            <property name=\"visible\">true</property>"
                                "            <property name=\"vexpand\">true</property>"
                                "            <child>"
                                "              <object class=\"GtkListBox\" id=\"todo_list\">"
                                "                <property name=\"visible\">true</property>"
                                "                <property name=\"selection-mode\">single</property>"
                                "              </object>"
                                "            </child>"
                                "          </object>"
                                "        </child>"
                                "      </object>"
                                "    </child>"
                                "  </object>"
                                "</interface>",
                                -1, nullptr);

    auto *window = GTK_WINDOW(gtk_builder_get_object(builder, "window"));
    auto *button = GTK_BUTTON(gtk_builder_get_object(builder, "add_button"));
    auto *list = GTK_LIST_BOX(gtk_builder_get_object(builder, "todo_list"));

    gtk_window_set_application(window, app);

    g_signal_connect(button, "clicked", G_CALLBACK(on_add_clicked), builder);
    g_signal_connect(list, "row-activated", G_CALLBACK(on_row_activated), nullptr);

    gtk_widget_show_all(GTK_WIDGET(window));
  }

  void hello_gui()
  {
    if (g_gtk_thread != nullptr)
    {
      g_print("GTK application is already running.\n");
      return;
    }

    if (!gtk_init_check(0, nullptr))
    {
      g_print("Failed to initialize GTK.\n");
      return;
    }

    g_gtk_main_context = g_main_context_new();
    g_main_loop = g_main_loop_new(g_gtk_main_context, FALSE);

    g_gtk_thread = new std::thread([]()
                                   {
        GtkApplication* app = gtk_application_new("com.example.todo", G_APPLICATION_NON_UNIQUE);
        g_signal_connect(app, "activate", G_CALLBACK(activate_handler), nullptr);

        g_idle_add_full(G_PRIORITY_DEFAULT, init_gtk_app, app, nullptr);

        if (g_main_loop) {
            g_main_loop_run(g_main_loop);
        } });

    g_gtk_thread->detach();
  }

  void cleanup_gui()
  {
    if (g_main_loop && g_main_loop_is_running(g_main_loop))
    {
      g_main_loop_quit(g_main_loop);
    }

    if (g_main_loop)
    {
      g_main_loop_unref(g_main_loop);
      g_main_loop = nullptr;
    }

    if (g_gtk_main_context)
    {
      g_main_context_unref(g_gtk_main_context);
      g_gtk_main_context = nullptr;
    }

    g_gtk_thread = nullptr;
  }

  void setTodoAddedCallback(TodoCallback callback)
  {
    g_todoAddedCallback = callback;
  }

  void setTodoUpdatedCallback(TodoCallback callback)
  {
    g_todoUpdatedCallback = callback;
  }

  void setTodoDeletedCallback(TodoCallback callback)
  {
    g_todoDeletedCallback = callback;
  }

} // namespace cpp_code

5) 创建 Node.js 插件桥接

现在,让我们在 src/cpp_addon.cc 中实现 C++ 代码与 Node.js 之间的桥接。首先,为我们的插件创建一个基本骨架:

src/cpp_addon.cc
#include <napi.h>
#include <string>
#include "cpp_code.h"

// Class to wrap our C++ code will go here

Napi::Object Init(Napi::Env env, Napi::Object exports) {
  // We'll add code here later
  return exports;
}

NODE_API_MODULE(cpp_addon, Init)

这是使用 node-addon-api 的 Node.js 插件所需的最小结构。当插件加载时,会调用 Init 函数,而 NODE_API_MODULE 宏会注册我们的初始化函数。这个基本骨架目前还没有任何功能,但它为 Node.js 提供了加载我们原生代码的入口点。

创建一个类来封装我们的 C++ 代码

让我们创建一个类,用于封装我们的 C++ 代码并将其暴露给 JavaScript。在上一步中,我们添加了一条注释,内容为 “Class to wrap our C++ code will go here”——请将其替换为下面的代码。

src/cpp_addon.cc
class CppAddon : public Napi::ObjectWrap<CppAddon>
{
public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports)
  {
    Napi::Function func = DefineClass(env, "CppLinuxAddon", {
      InstanceMethod("helloWorld", &CppAddon::HelloWorld),
      InstanceMethod("helloGui", &CppAddon::HelloGui),
      InstanceMethod("on", &CppAddon::On),
      InstanceMethod("destroy", &CppAddon::Destroy)
    });

    Napi::FunctionReference *constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);

    exports.Set("CppLinuxAddon", func);
    return exports;
  }

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    // We'll implement the constructor together with a callback struct later
  }

  ~CppAddon()
  {
    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
      tsfn_ = nullptr;
    }
  }

private:
  Napi::Env env_;
  Napi::ObjectReference emitter;
  Napi::ObjectReference callbacks;
  napi_threadsafe_function tsfn_;

  // Method implementations will go here
};

在这里,我们创建一个继承自 Napi::ObjectWrap<CppAddon> 的 C++ 类:

static Napi::Object Init 定义了我们的 JavaScript 接口,包含四个方法:

  • helloWorld:一个用于测试桥接的简单函数
  • helloGui:用于启动我们的 GTK3 用户界面的函数
  • on:用于注册事件回调的方法
  • destroy:用于在应用退出前释放所有持久引用的方法

构造函数会初始化:

  • emitter:一个用于向 JavaScript 发出事件的对象
  • callbacks:已注册的 JavaScript 回调函数映射
  • tsfn_:一个线程安全函数句柄(对 GTK3 线程通信至关重要)

析构函数会在对象被垃圾回收时正确清理线程安全函数。

实现基本功能 - HelloWorld

接下来,我们将添加两个主要方法:HelloWorld() 和 HelloGui()。我们会将它们添加到 private 作用域中,也就是注释 “Method implementations will go here” 所在的位置。

src/cpp_addon.cc
Napi::Value HelloWorld(const Napi::CallbackInfo &info)
{
  Napi::Env env = info.Env();

  if (info.Length() < 1 || !info[0].IsString())
  {
    Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
    return env.Null();
  }

  std::string input = info[0].As<Napi::String>();
  std::string result = cpp_code::hello_world(input);

  return Napi::String::New(env, result);
}

void HelloGui(const Napi::CallbackInfo &info)
{
  cpp_code::hello_gui();
}

// On() method implementation will go here

HelloWorld():

  • 验证输入参数(必须是字符串)
  • 调用我们的 C++ hello_world 函数
  • 将结果作为 JavaScript 字符串返回

HelloGui():

  • 简单地调用我们的 C++ hello_gui 函数,不传参数
  • 由于该函数只是启动用户界面,因此不返回任何内容(void)
  • 这些方法构成了 JavaScript 调用与我们原生 C++ 函数之间的直接桥接。

你可能会想知道 Napi::CallbackInfo 是什么,或者它来自哪里。这是由 Node-API(N-API)C++ 封装提供的类,具体来自 node-addon-api 包。它封装了关于 JavaScript 函数调用的所有信息,包括:

  • 从 JavaScript 传入的参数
  • JavaScript 执行环境(通过 info.Env())
  • 函数调用的 this 值
  • 参数数量(通过 info.Length())

这个类对于 Node.js 原生插件开发至关重要,因为它充当 JavaScript 函数调用与 C++ 方法实现之间的桥接。每个可以从 JavaScript 调用的原生方法都会接收一个 CallbackInfo 对象作为参数,使 C++ 代码能够在处理 JavaScript 参数之前访问并验证这些参数。你可以在 HelloWorld() 中看到我们使用它来获取函数参数以及关于函数调用的其他信息。我们的 HelloGui() 函数没有使用它,但如果使用了,也会遵循相同的模式。

设置事件系统

现在,我们将处理原生开发中比较棘手的一部分:设置事件系统。之前,我们在 cpp_code.cc 代码中添加了原生回调——而在 cpp_addon.cc 中的桥接代码里,我们需要找到一种方式,让这些回调最终触发一个 JavaScript 方法。

让我们从 On() 方法开始,我们会从 JavaScript 中调用它。在我们之前编写的代码中,你会看到一条注释,内容为 On() method implementation will go here。请将其替换为以下方法:

src/cpp_addon.cc
Napi::Value On(const Napi::CallbackInfo &info)
{
  Napi::Env env = info.Env();

  if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction())
  {
    Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
    return env.Undefined();
  }

  callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
  return env.Undefined();
}

This method allows JavaScript to register callbacks for different event types and stores the JavaScript function in our callbacks map for later use. So far, so good - but now we need to let cpp_code.cc know about these callbacks. We also need to figure out a way to coordinate our threads, because the actual cpp_code.cc will be doing most of its work on its own thread.

In our code, find the section where we're declaring the constructor CppAddon(const Napi::CallbackInfo &info), which you'll find in the public section. It should have a comment reading We'll implement the constructor together with a callback struct later. Then, replace that part with the following code:

src/cpp_addon.cc
  struct CallbackData
  {
    std::string eventType;
    std::string payload;
    CppAddon *addon;
  };

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    napi_status status = napi_create_threadsafe_function(
        env_,
        nullptr,
        nullptr,
        Napi::String::New(env_, "CppCallback"),
        0,
        1,
        nullptr,
        nullptr,
        this,
        [](napi_env env, napi_value js_callback, void *context, void *data)
        {
          auto *callbackData = static_cast<CallbackData *>(data);
          if (!callbackData)
            return;

          Napi::Env napi_env(env);
          Napi::HandleScope scope(napi_env);

          auto addon = static_cast<CppAddon *>(context);
          if (!addon)
          {
            delete callbackData;
            return;
          }

          try
          {
            auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
            if (callback.IsFunction())
            {
              callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
            }
          }
          catch (...)
          {
          }

          delete callbackData;
        },
        &tsfn_);

    if (status != napi_ok)
    {
      Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
      return;
    }

    // Set up the callbacks here
    auto makeCallback = [this](const std::string &eventType)
    {
      return [this, eventType](const std::string &payload)
      {
        if (tsfn_ != nullptr)
        {
          auto *data = new CallbackData{
              eventType,
              payload,
              this};
          napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
        }
      };
    };

    cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
    cpp_code::setTodoUpdatedCallback(makeCallback("todoUpdated"));
    cpp_code::setTodoDeletedCallback(makeCallback("todoDeleted"));
  }

This is the most complex part of our bridge: implementing bidirectional communication. There are a few things worth noting going on here, so let's take them step by step:

CallbackData struct:

  • Holds the event type, JSON payload, and a reference to our addon.

In the constructor:

  • We create a thread-safe function (napi_create_threadsafe_function) which is crucial for calling into JavaScript from the GTK3 thread
  • The thread-safe function callback unpacks the data and calls the appropriate JavaScript callback
  • We create a lambda makeCallback that produces callback functions for different event types
  • We register these callbacks with our C++ code using the setter functions

Let's talk about napi_create_threadsafe_function. The orchestration of different threads is maybe the most difficult part about native addon development - and in our experience, the place where developers are most likely to give up. napi_create_threadsafe_function is provided by the N-API and allows you to safely call JavaScript functions from any thread. This is essential when working with GUI frameworks like GTK3 that run on their own thread. Here's why it's important:

  1. Thread Safety: JavaScript in Electron runs on a single thread (exceptions apply, but this is a generally useful rule). Without thread-safe functions, calling JavaScript from another thread would cause crashes or race conditions.
  2. Queue Management: It automatically queues function calls and executes them on the JavaScript thread.
  3. Resource Management: It handles proper reference counting to ensure objects aren't garbage collected while still needed.

In our code, we're using it to bridge the gap between GTK3's event loop and Node.js's event loop, allowing events from our GUI to safely trigger JavaScript callbacks.

For developers wanting to learn more, you can refer to the official N-API documentation for detailed information about thread-safe functions, the node-addon-api wrapper documentation for the C++ wrapper implementation, and the Node.js Threading Model article to understand how Node.js handles concurrency and why thread-safe functions are necessary.

Putting cpp_addon.cc together

We've now finished the bridge part of our addon - that is, the code that's most concerned with being the bridge between your JavaScript and C++ code (and by contrast, less so actually interacting with the operating system or GTK). After adding all the sections above, your src/cpp_addon.cc should look like this:

src/cpp_addon.cc
#include <napi.h>
#include <string>
#include "cpp_code.h"

class CppAddon : public Napi::ObjectWrap<CppAddon>
{
public:
  static Napi::Object Init(Napi::Env env, Napi::Object exports)
  {
    Napi::Function func = DefineClass(env, "CppLinuxAddon", {
      InstanceMethod("helloWorld", &CppAddon::HelloWorld),
      InstanceMethod("helloGui", &CppAddon::HelloGui),
      InstanceMethod("on", &CppAddon::On),
      InstanceMethod("destroy", &CppAddon::Destroy)
    });

    Napi::FunctionReference *constructor = new Napi::FunctionReference();
    *constructor = Napi::Persistent(func);
    env.SetInstanceData(constructor);

    exports.Set("CppLinuxAddon", func);
    return exports;
  }

  struct CallbackData
  {
    std::string eventType;
    std::string payload;
    CppAddon *addon;
  };

  CppAddon(const Napi::CallbackInfo &info)
      : Napi::ObjectWrap<CppAddon>(info),
        env_(info.Env()),
        emitter(Napi::Persistent(Napi::Object::New(info.Env()))),
        callbacks(Napi::Persistent(Napi::Object::New(info.Env()))),
        tsfn_(nullptr)
  {
    napi_status status = napi_create_threadsafe_function(
        env_,
        nullptr,
        nullptr,
        Napi::String::New(env_, "CppCallback"),
        0,
        1,
        nullptr,
        nullptr,
        this,
        [](napi_env env, napi_value js_callback, void *context, void *data)
        {
          auto *callbackData = static_cast<CallbackData *>(data);
          if (!callbackData)
            return;

          Napi::Env napi_env(env);
          Napi::HandleScope scope(napi_env);

          auto addon = static_cast<CppAddon *>(context);
          if (!addon)
          {
            delete callbackData;
            return;
          }

          try
          {
            auto callback = addon->callbacks.Value().Get(callbackData->eventType).As<Napi::Function>();
            if (callback.IsFunction())
            {
              callback.Call(addon->emitter.Value(), {Napi::String::New(napi_env, callbackData->payload)});
            }
          }
          catch (...)
          {
          }

          delete callbackData;
        },
        &tsfn_);

    if (status != napi_ok)
    {
      Napi::Error::New(env_, "Failed to create threadsafe function").ThrowAsJavaScriptException();
      return;
    }

    // Set up the callbacks here
    auto makeCallback = [this](const std::string &eventType)
    {
      return [this, eventType](const std::string &payload)
      {
        if (tsfn_ != nullptr)
        {
          auto *data = new CallbackData{
              eventType,
              payload,
              this};
          napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
        }
      };
    };

    cpp_code::setTodoAddedCallback(makeCallback("todoAdded"));
    cpp_code::setTodoUpdatedCallback(makeCallback("todoUpdated"));
    cpp_code::setTodoDeletedCallback(makeCallback("todoDeleted"));
  }

  ~CppAddon()
  {
    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_release);
      tsfn_ = nullptr;
    }
  }

private:
  Napi::Env env_;
  Napi::ObjectReference emitter;
  Napi::ObjectReference callbacks;
  napi_threadsafe_function tsfn_;

  Napi::Value HelloWorld(const Napi::CallbackInfo &info)
  {
    Napi::Env env = info.Env();

    if (info.Length() < 1 || !info[0].IsString())
    {
      Napi::TypeError::New(env, "Expected string argument").ThrowAsJavaScriptException();
      return env.Null();
    }

    std::string input = info[0].As<Napi::String>();
    std::string result = cpp_code::hello_world(input);

    return Napi::String::New(env, result);
  }

  void HelloGui(const Napi::CallbackInfo &info)
  {
    cpp_code::hello_gui();
  }

  Napi::Value On(const Napi::CallbackInfo &info)
  {
    Napi::Env env = info.Env();

    if (info.Length() < 2 || !info[0].IsString() || !info[1].IsFunction())
    {
      Napi::TypeError::New(env, "Expected (string, function) arguments").ThrowAsJavaScriptException();
      return env.Undefined();
    }

    callbacks.Value().Set(info[0].As<Napi::String>(), info[1].As<Napi::Function>());
    return env.Undefined();
  }

  Napi::Value Destroy(const Napi::CallbackInfo &info)
  {
    callbacks.Reset();
    emitter.Reset();

    if (tsfn_ != nullptr)
    {
      napi_release_threadsafe_function(tsfn_, napi_tsfn_abort);
      tsfn_ = nullptr;
    }

    return info.Env().Undefined();
  }
};

Napi::Object Init(Napi::Env env, Napi::Object exports)
{
  return CppAddon::Init(env, exports);
}

NODE_API_MODULE(cpp_addon, Init)

6) 创建 JavaScript 包装器

让我们通过在 js/index.js 中添加一个 JavaScript 包装器来收尾。正如大家所见,C++ 需要大量样板代码,而这些代码用 JavaScript 编写可能更容易或更快——你会发现许多生产级应用最终都会在调用原生代码之前,使用 JavaScript 对数据或请求进行转换。例如,我们会将时间戳转换为正确的 JavaScript 日期。

js/index.js
const EventEmitter = require('events');

class CppLinuxAddon extends EventEmitter {
  constructor() {
    super()

    if (process.platform !== 'linux') {
      throw new Error('This module is only available on Linux');
    }

    const native = require('bindings')('cpp_addon')
    this.addon = new native.CppLinuxAddon()

    // Set up event forwarding
    this.addon.on('todoAdded', (payload) => {
      this.emit('todoAdded', this.parse(payload))
    });

    this.addon.on('todoUpdated', (payload) => {
      this.emit('todoUpdated', this.parse(payload))
    })

    this.addon.on('todoDeleted', (payload) => {
      this.emit('todoDeleted', this.parse(payload))
    })
  }

  helloWorld(input = "") {
    return this.addon.helloWorld(input)
  }

  helloGui() {
    return this.addon.helloGui()
  }

  destroy() {
    this.addon.destroy()
  }

  // Parse JSON and convert date to JavaScript Date object
  parse(payload) {
    const parsed = JSON.parse(payload)

    return { ...parsed, date: new Date(parsed.date) }
  }
}

if (process.platform === 'linux') {
  module.exports = new CppLinuxAddon()
} else {
  // Return empty object on non-Linux platforms
  module.exports = {}
}

这个包装器:

  • 继承 EventEmitter 以进行原生事件处理
  • 仅在 Linux 平台上加载
  • 将事件从 C++ 转发到 JavaScript
  • 提供简洁的方法来调用 C++
  • 提供 destroy() 方法以释放原生资源
  • 将 JSON 数据转换为正确的 JavaScript 对象

[!IMPORTANT] 在应用退出之前必须调用 destroy()(例如在 will-quit 或 before-quit 事件处理器中)。如果没有这样做,对回调和线程安全函数的持久引用将阻止原生插件的析构函数运行,导致 Electron 在退出时挂起。

7) 构建和测试插件

在所有文件就位后,你可以构建插件:

npm run build

如果构建完成,你就可以将该插件添加到你的 Electron 应用中,并在那里 import 或 require 它。

使用示例

构建插件后,你可以在 Electron 应用中使用它。下面是一个完整示例:

```js @ts-expect-error=[2] // In your Electron main process or renderer process import cppLinux from 'cpp-linux'

// Test the basic functionality console.log(cppLinux.helloWorld('Hi!')) // Output: "Hello from C++! You said: Hi!"

// Set up event listeners for GTK GUI interactions cppLinux.on('todoAdded', (todo) => { console.log('New todo added:', todo) // todo: { id: "uuid-string", text: "Todo text", date: Date object } })

cppLinux.on('todoUpdated', (todo) => { console.log('Todo updated:', todo) })

cppLinux.on('todoDeleted', (todo) => { console.log('Todo deleted:', todo) })

// Launch the native GTK GUI cppLinux.helloGui() ```

当你运行这段代码时:

  1. helloWorld() 调用将返回来自 C++ 的问候
  2. 当用户与 GTK3 GUI 交互时,事件监听器会被触发
  3. helloGui() 调用将打开一个原生 GTK3 窗口,其中包含:
  4. 一个用于待办事项的文本输入框
  5. 一个用于选择日期的日历控件
  6. 一个用于创建新待办事项的“添加”按钮
  7. 一个显示所有待办事项的可滚动列表
  8. 用于编辑和删除待办事项的右键上下文菜单

所有与原生 GTK3 界面的交互都会触发对应的 JavaScript 事件,使你的 Electron 应用能够实时响应原生 GUI 操作。

结论

你现在已经使用 C++ 和 GTK3 为 Linux 构建了一个完整的原生 Node.js 插件。该插件:

  1. 在 JavaScript 和 C++ 之间提供双向桥接
  2. 创建一个在独立线程中运行的原生 GTK3 GUI
  3. 实现了一个具有添加功能的简单待办应用
  4. 使用 GTK3,它与 Electron 的 Chromium 运行时兼容
  5. 安全地处理从 C++ 到 JavaScript 的回调

这个基础可以扩展,用于在 Electron 应用中实现更复杂的 Linux 特定功能。你可以访问系统功能,集成 Linux 特定库,或创建高性能的原生 UI,同时保持 Electron 提供的灵活性和开发便捷性。 有关 GTK3 开发的更多信息,请参阅 GTK3 文档 和 GLib/GObject 文档。你还会发现 Node.js N-API 文档 和 node-addon-api 对于扩展你的原生插件很有帮助。

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 el/electron