Electron 原生模块开发(Linux / C++):用 GTK3 打造可双向通信的原生界面
本教程是在 Native Code and Electron 通用指南 基础上的 Linux 平台实战篇,核心目标是用 C++ 与 GTK3 编写一个能嵌入 Electron 应用的原生 Node.js Addon。文章将带你从零搭建一个完整的 Todo 示例:通过 node-gyp 完成 Linux 条件化构建、让 GTK3 GUI 运行在独立线程上、并借助 N-API 线程安全函数(ThreadSafe Function)实现"JavaScript → C++ → GTK3"与"GTK3 → C++ → JavaScript"的双向通信。读完你既能掌握 GTK3 原生界面与 Electron 共存的工程化方法,也能理解其中最容易出错的线程模型与回调安全边界。
[!NOTE] 阅读前置 本教程面向已具备基础 GTK 开发经验的读者,需要了解 widget、signal、主事件循环等核心概念;为保证篇幅,教程不会逐行解释所用 GTK 元素。若尚不熟悉,建议先阅读 GTK3 官方文档与入门教程,GNOME 开发者文档也提供了更全面的 GTK 开发指南。
为什么是 GTK3 而不是 GTK4
本教程选择 GTK3 作为 GUI 方案,除了它本身提供的丰富控件能力(按钮、输入框、列表等)之外,最关键的原因与 Electron 的运行时基石直接相关:
- Chromium(以及由它衍生的 Electron)内部使用的正是 GTK3;
- 如果同时在一个进程中加载 GTK3 与 GTK4,会引发运行时符号冲突;
- 一旦未来 Chromium 升级到 GTK4,你的原生代码通常可以平滑迁移过去。
这一点在当前仓库源码中可以得到佐证:Electron 的 Linux 侧大量直接依赖 GTK3,例如 shell/browser/browser_linux.cc 直接 #include <gtk/gtk.h>,并在图标主题解析逻辑中通过 gtk::GtkCheckVersion(4) 分别处理 GTK3/GTK4 两套 API(见 shell/browser/browser_linux.cc),shell/browser/printing/printing_utils.cc、shell/browser/ui/gtk/menu_gtk.cc 等文件也都以 GTK3 头文件为基础。也就是说,本教程所做的一切,正是站在 Electron 自身在 Linux 上所使用的原生技术栈之上。
环境要求(Requirements)
除 通用教程 中提到的 Node.js、npm 与本地编译工具链外,本教程还需要:
- 安装了 GTK3 开发文件的 Linux 发行版
- pkg-config 工具(用于自动探测 GTK3 的编译参数)
- 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. 创建 Addon 包结构与依赖
可以复用 Native Code and Electron 教程中创建的包(前序步骤不再重复)。先规划 addon 的目录结构:
cpp-linux/
├── binding.gyp # 供 node-gyp 构建原生 addon 的配置文件
├── include/
│ └── cpp_code.h # 声明原生 C++ 代码的接口头文件
├── js/
│ └── index.js # 加载并对外暴露原生 addon 的 JavaScript 封装
├── package.json # Node.js 包配置与依赖
└── src/
├── cpp_addon.cc # 桥接 Node.js/Electron 与原生代码的 C++ 文件
└── cpp_code.cc # 基于 GTK3 的原生 C++ 功能实现
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"
}
}
三个脚本的分工:
build:执行node-gyp configure && node-gyp build,把 C++ 源码编译成.node二进制;build-electron:调用electron-rebuild。需要说明的是,Electron 的 ABI 与 Node.js 不同(例如使用 Chromium 的 BoringSSL 而非 OpenSSL),因此安装到 Electron 应用时必须按目标 Electron 版本重新编译,相关原理与@electron/rebuild用法详见 Native Node Modules;clean:删除build目录以获得全新构建。
依赖方面,node-addon-api 是底层 N-API 的 C++ 面向对象封装,bindings 则自动定位编译产物 .node 文件,二者已在通用教程中详细说明。
2. 配置 Linux 专属构建(binding.gyp)
对于一个依赖 GTK3 的 Linux 专属 addon,需要让 binding.gyp 只在 Linux 上编译、在其他平台尽量不做任何事,并借助 pkg-config 自动获取用户系统上 GTK3 的库与头文件路径,同时配置异常处理与线程支持等编译选项:
{
"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\")"
]
}]
]
}
]
}
关键点解析:
| 配置项 | 作用 |
|---|---|
conditions: [['OS=="linux"', {...}]] |
GYP 的条件编译机制,整个 target 的源文件与编译选项只在 Linux 生效,实现平台隔离 |
<!@(...) |
命令展开操作符:执行括号内的命令并把输出当作该位置的值。所有内嵌 pkg-config 的写法都是在调用 pkg-config 并取用其输出 |
pkg-config --cflags-only-I gtk+-3.0 | sed s/-I//g |
只提取 GTK3 的 include 目录,再让 sed 去掉 -I 前缀,转换为 GYP 兼容的头文件搜索路径格式 |
pkg-config --libs gtk+-3.0 |
输出链接 GTK3 所需的 -l 库参数,加入 libraries |
-fexceptions / cflags!、cflags_cc! 中的 -fno-exceptions |
强制打开 C++ 异常支持并移除 node-gyp 默认的 -fno-exceptions,配合 defines 中的 NODE_ADDON_API_CPP_EXCEPTIONS,让 node-addon-api 的异常机制可以正常工作 |
-pthread |
开启 POSIX 线程支持,GTK 运行线程与本教程的线程模型依赖它 |
-luuid |
链接 libuuid,用于 uuid/uuid.h 提供的 UUID 生成接口 |
dependencies 中 <!(node -p ...) |
引入 node-addon-api 自带的 gyp 配置片段 |
3. 定义 C++ 接口(include/cpp_code.h)
头文件声明了将暴露给 JS 侧的全部原生能力:
#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
#pragma once是防止同一编译单元内重复包含的头文件守卫;hello_world:一个用于验证桥接链路的基础函数;hello_gui:创建 GTK3 GUI 的入口;TodoCallback类型别名 + 三个 setter:供上层桥接代码注册"新增/更新/删除 Todo"三类事件回调,是原生侧通知 JavaScript 的通道。
4. 实现 GTK3 GUI(src/cpp_code.cc)
GTK 代码往往相当冗长,因此分小节逐步拼装。最终文件需要同时涵盖数据结构、全局状态、线程与主循环管理、事件处理以及回调管理。
4.1 基础配置与数据结构
#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使用 UUID(uuid_t) 唯一标识每条待办,记录文本与毫秒级时间戳,并提供两个关键成员:toJson():把对象序列化为 JSON 字符串。它是 C++ 对象发送到 JavaScript 的前提——教程刻意保持 JSON 手写序列化的直白风格,生产代码可选用各类 C++ JSON 库;formatDate():把时间戳格式化为%Y-%m-%d便于显示。
注意:目前尚无任何用户界面,下一步才开始搭 UI。
4.2 全局状态与前向声明
// 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;
}
匿名命名空间中的全局变量负责跨函数共享应用状态:
- 三个
TodoCallback:Todo 增/改/删操作的对外回调; g_gtk_main_context/g_main_loop:GTK 主上下文与主循环指针,是线程协作的核心;g_gtk_thread:GTK 运行线程指针;g_todos:存放待办数据的容器。
线程模型在这里定型:GTK 并非线程安全库,所有 UI 操作必须发生在其主上下文中。由于 addon 的函数会被 Node.js/Electron 的 JS 线程调用,若直接在 JS 线程跑 GTK 事件循环,会阻塞 JavaScript 事件循环——所以必须把 GTK 放到独立线程,同时保持与 Electron 应用的双向通信。这正是教程中最需要被理解的设计动机。
4.3 工具函数(helper)
// 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 主上下文中执行——因为 GTK 非线程安全,回调必须在主上下文内运行;数据以std::pair<TodoCallback, std::string>动态分配传递、执行后删除;update_todo_row_label:重建列表行的文本标签(含格式化后的日期);create_todo_dialog:创建"新增/编辑 Todo"对话框,包含文本输入框、日历控件,以及带键盘助记符的 _Cancel / _Save 按钮。若传入existing_todo,会预填文本并让日历选中已有日期。
4.4 事件处理器(event handlers)
这段代码里唯一"Electron 特有"的部分,就是事件发生时向 JS 回调发通知:
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:取选中行及其索引,用该条 Todo 初始化编辑对话框;用户确认(GTK_RESPONSE_ACCEPT)后,把输入框文本与日历日期写回g_todos[index],刷新行标签并通过g_todoUpdatedCallback通知 JS;delete_action:从GtkListBox与g_todos中移除目标行,并发出g_todoDeletedCallback;on_add_clicked:点击 Add 时校验文本非空,uuid_generate生成唯一 ID,把日历选择转换为毫秒时间戳,push 进容器、动态创建列表行,随后清空输入框并发出g_todoAddedCallback;on_row_activated:行被点击时基于GMenu模型弹出 popover 菜单(Edit / Delete 两个 action 对应app.edit、app.delete)。
4.5 GTK 应用初始化与 activate 处理
这一步"反直觉"的地方在于:Electron 本身是 GTK 应用,为什么原生代码还要再初始化一套 GTK 应用?原因在于这是运行在 Electron 进程旁边的原生 C++ 代码——它由 Electron 启动、拥有自己的原生窗口,因此在独立线程上运行自己的 GApplication 主循环:
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:调用g_application_run真正跑起 GTK 应用,应用退出后解除循环;activate_handler:构建 UI 时做四件事——- 用
GActionEntry数组把edit/delete两个 action 注册到应用(与上文 popover 的app.edit/app.delete对应,注意把builder作为user_data传入 action); - 用 GTK XML 描述 UI(GtkBuilder 内联字符串)声明窗口结构:400×500 的主窗口、横向排列的输入控件条(GtkEntry + GtkCalendar + Add 按钮)、可滚动的单行选择
GtkListBox; - 通过
g_signal_connect把button.clicked接到on_add_clicked、把list.row-activated接到on_row_activated; gtk_widget_show_all显示窗口。
- 用
4.6 主 GUI 入口与线程管理
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;
}
生命周期管理:
hello_gui()(暴露给 JavaScript 的入口):- 幂等保护:若
g_gtk_thread已非空则直接返回; gtk_init_check探测 GTK 初始化是否成功;- 新建 GTK 主上下文与主循环;
- 在
std::thread内创建GtkApplication(应用 IDcom.example.todo,G_APPLICATION_NON_UNIQUE允许同应用多实例),连接activate信号; g_idle_add_full让init_gtk_app在主循环空闲时执行g_application_run;detach()分离线程,使其独立运行、不被 C++ 线程析构影响。
- 幂等保护:若
cleanup_gui():先退出运行中的主循环,再按引用计数释放GMainLoop与GMainContext,并把线程指针置空。
再次强调线程分离的原因:把 GTK 主循环放进独立线程,才能避免其阻塞 Node.js 的事件循环,这是 GTK GUI 能嵌入 Electron 进程模型的前提。
4.7 回调注册
void setTodoAddedCallback(TodoCallback callback)
{
g_todoAddedCallback = callback;
}
void setTodoUpdatedCallback(TodoCallback callback)
{
g_todoUpdatedCallback = callback;
}
void setTodoDeletedCallback(TodoCallback callback)
{
g_todoDeletedCallback = callback;
}
三个 setter 把桥接层传入的回调写入对应全局变量,从而完成"桥接层 → 原生层"这一方向的事件注入。
至此,src/cpp_code.cc 已完整包含基础函数、数据结构、前向声明、全局状态、工具函数、事件处理器、应用初始化、线程生命周期与回调管理九个部分。原生侧与操作系统(GTK)打交道的代码已经完成,下一步进入与 JavaScript 世界桥接的部分。
5. 编写 Node.js Addon 桥接层(src/cpp_addon.cc)
5.1 最小骨架
#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 addon 最小结构:加载时调用 Init,NODE_API_MODULE 宏负责注册初始化函数。
5.2 包装类 CppAddon
把骨架中"Class to wrap our C++ code will go here"注释替换为如下类定义:
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> 获得 JS 对象包装能力:
static Init用DefineClass定义 JS 类CppLinuxAddon,暴露四个实例方法:helloWorld(验证桥接)、helloGui(启动 GTK3 UI)、on(注册事件回调)、destroy(退出前释放所有持久引用);- 构造器持有三个关键成员:
emitter(用于向 JS 发事件的接收对象)、callbacks(已注册 JS 回调函数表)、tsfn_(线程安全函数句柄,是 GTK3 线程通信的核心); - 析构函数在对象被 GC 时释放线程安全函数。
5.3 HelloWorld 与 HelloGui 方法实现
替换私有区"Method implementations will go here":
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():校验第一个参数必须是字符串,把 JS 字符串转成 C++std::string,调用cpp_code::hello_world后返回 JS 字符串;HelloGui():无参数直接转发给cpp_code::hello_gui(),返回值 void,因为该方法只负责拉起窗口。
这里不断出现的 Napi::CallbackInfo 来自 node-addon-api(N-API 的 C++ 封装)。它封装了某次 JS 调用的全部信息,包括:传入参数、执行环境(info.Env())、this 值、参数个数(info.Length())。凡是可从 JS 调用的原生方法都会收到一个 CallbackInfo,C++ 代码借此完成参数校验后再处理——HelloWorld() 正是标准用法示例。
5.4 事件系统:TSFN 桥接 GTK 线程与 JS 线程
事件系统是原生开发最棘手的部分:cpp_code.cc 中的回调是在 GTK 线程 被触发的,而 JavaScript 只能在 Node.js/Electron 主线程 执行,必须通过线程安全函数把跨线程调用编排到 JS 线程。
先替换 On() method implementation will go here:
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();
}
On() 接受 (事件名, 函数) 并把 JS 回调存进 callbacks 表,供后续事件触发时查找。
接下来替换构造器中"…callback struct later"注释处,加入 CallbackData 结构体与完整的线程安全函数初始化逻辑:
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
{
return this, eventType
{
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"));
}
这是整个桥接层最复杂的部分,逐步拆解:
CallbackData:一次跨线程调用所需携带的数据,包含事件类型、JSON 负载与 addon 实例指针;napi_create_threadsafe_function:创建线程安全函数。其回调会在 Node.js 主线程被调度执行,流程是:把data还原为CallbackData,用HandleScope管理局部句柄,从callbacks表中按事件名取出对应 JS 函数,以emitter为this调用之,最后释放data;makeCallbacklambda 工厂:为某事件生成一个可被cpp_code调用、且能安全投递到 JS 线程的闭包——内部把事件名、负载封装进堆上分配的CallbackData,再以napi_tsfn_blocking模式调用napi_call_threadsafe_function入队;- 构造器末尾把三个闭包分别注册给
cpp_code的setTodoAddedCallback/setTodoUpdatedCallback/setTodoDeletedCallback,对接 4.7 节的 setter。
为什么 TSFN 是刚需,三点原因:
- 线程安全:Electron 中 JS 运行在单一线程,跨线程直接调用 JS 会崩溃或产生竞态;
- 队列管理:TSFN 自动把跨线程调用排入队列、统一在 JS 线程上执行;
- 资源管理:正确的引用计数保证对象在使用期间不会被 GC 回收。
它把 GTK3 事件循环与 Node.js 事件循环缝合起来,使 GUI 上发生的交互能安全触发 JS 回调。如果想深入了解,可参阅 N-API 官方文档中 napi_create_threadsafe_function 条目、node-addon-api 的 ThreadSafe Function 封装说明,以及 Node.js 官方关于"不要阻塞事件循环"的线程模型文章。
5.5 cpp_addon.cc 完整文件
把上述所有片段按序拼装后,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
{
return this, eventType
{
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)
与前面片段相比,完整文件里多出 Destroy() 的实现:它重置 callbacks 与 emitter 的持久引用,并以 napi_tsfn_abort 模式释放线程安全函数——这会中止排队中的待执行调用,是退出清理的关键一步。模块注册处则把导出的 Init 转发给 CppAddon::Init。
6. 创建 JavaScript 封装(js/index.js)
C++ 侧充满样板代码,许多逻辑(例如数据转换)放在 JavaScript 里更高效——不少生产应用都会在调用原生代码前先做数据整形,例如本封装把时间戳转成真正的 JS Date:
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,为原生事件提供统一的 Node.js 事件接口; - 只在 Linux 平台加载真实实现,其余平台导出空对象作降级;
- 构造器通过
bindings('cpp_addon')定位编译产物并实例化原生类,随后把原生层的todoAdded/todoUpdated/todoDeleted三个事件逐一转发为自身事件,且在转发时用parse()把 JSON 负载解析为对象、把毫秒时间戳变成Date实例; helloWorld / helloGui / destroy对外暴露简洁方法。
[!IMPORTANT] 应用退出前(例如
will-quit或before-quit事件处理器中)必须调用destroy()。否则对回调与线程安全函数的持久引用会阻止原生 addon 析构函数执行,导致 Electron 退出时挂起。
7. 构建并接入 Electron 应用
所有文件就位后,编译 addon:
npm run build
编译成功即得到一个可供 Electron 加载的 .node 模块。将 addon 作为依赖放入 Electron 工程后,需要按目标 Electron 版本执行重建(npm run build-electron 对应 electron-rebuild),原因与做法参见 Native Node Modules。
使用示例
// 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()
运行时的实际表现:
helloWorld()返回来自 C++ 的问候语;- 三个事件监听器会在用户操作 GTK3 界面时被触发;
helloGui()打开一个原生 GTK3 窗口,包含:待办文本输入框、日期选择日历、"Add" 按钮、可滚动列表;列表行被激活时会弹出 Edit / Delete 上下文菜单。
对原生 GTK3 界面的一切操作都会触发对应的 JavaScript 事件,Electron 应用因此可以实时响应原生 GUI 行为。此外,读者可以在 Electron 仓库源码 中看到 Electron 自身在 Linux 侧如何使用 GTK3 处理图标主题、菜单与打印对话框(如 shell/browser/browser_linux.cc、shell/browser/ui/gtk/menu_gtk.cc),以加深对"原生代码与 Chromium 共享 GTK3 运行时"这一前提的理解。
总结
你已完成一个面向 Linux 的完整 C++/GTK3 原生 addon:
- 打通 JavaScript 与 C++ 的双向桥接;
- 让原生 GTK3 GUI 在独立线程中运行,不阻塞 JS 事件循环;
- 实现一个具备增删改、日期选择与右键菜单的 Todo 示例应用;
- 采用与 Electron 内置 Chromium 运行时一致的 GTK3;
- 借助线程安全函数安全地处理 C++ → JavaScript 的回调。
这套基础可以继续扩展,用于在 Electron 中实现更复杂的 Linux 专属能力——访问系统特性、集成 Linux 原生库,或打造高性能原生界面。进一步了解 GTK3 可参考 GTK3 文档与 GLib/GObject 文档;扩展原生 addon 能力时可参考 N-API 与 node-addon-api 的官方说明。若需在其他平台实践类似模式,可继续阅读 Windows / C++ 篇、macOS / Objective-C 篇 与 macOS / Swift 篇。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00