跳转到主要内容

原生代码与 Electron:Swift (macOS)

本教程建立在原生代码与 Electron 的通用介绍的基础上,专注于使用 Swift 为 macOS 创建原生插件。

Swift 是一种现代、强大的语言,旨在提供安全性和高性能。虽然你无法像使用 Electron 的 Node.js N-API 那样直接使用 Swift,但你可以使用 Objective-C++ 创建一个桥接,将 Swift 与 Electron 应用程序中的 JavaScript 连接起来。

为了说明如何将原生 macOS 代码嵌入到你的 Electron 应用中,我们将构建一个基本的原生 macOS GUI(使用 SwiftUI),该 GUI 与 Electron 的 JavaScript 进行通信。

本教程对于那些已经熟悉 Objective-C、Swift 和 SwiftUI 开发的人最有帮助。你应该理解 Swift 语法、可选类型、闭包、SwiftUI 视图、属性包装器等基本概念,以及 Objective-C/Swift 互操作机制,例如 @objc 属性和桥接头文件。

注意

如果你还不熟悉这些概念,Apple 的Swift 编程语言指南SwiftUI 文档Swift 和 Objective-C 互操作性指南是极好的起点。

要求

与我们原生代码与 Electron 的通用介绍一样,本教程假定你已安装 Node.js 和 npm,以及在 macOS 上编译原生代码所需的基本工具。你需要

  • 安装 Xcode(可从 Mac App Store 获取)
  • Xcode 命令行工具(可以通过在终端中运行 xcode-select --install 来安装)

1) 创建一个包

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

swift-native-addon/
├── binding.gyp # Build configuration
├── include/
│ └── SwiftBridge.h # Objective-C header for the bridge
├── js/
│ └── index.js # JavaScript interface
├── package.json # Package configuration
└── src/
├── SwiftCode.swift # Swift implementation
├── SwiftBridge.m # Objective-C bridge implementation
└── swift_addon.mm # Node.js addon implementation

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

package.json
{
"name": "swift-macos",
"version": "1.0.0",
"description": "A demo module that exposes Swift 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": {
"bindings": "^1.5.0",
"node-addon-api": "^8.3.0"
},
"devDependencies": {
"node-gyp": "^11.1.0"
}
}

2) 配置构建设置

在我们关注其他原生语言的其他教程中,我们可以使用 node-gyp 来构建所有代码。对于 Swift,情况有点棘手:我们需要先构建,然后链接我们的 Swift 代码。这是因为 Swift 有自己的编译模型和运行时要求,它们不直接集成到 node-gyp 以 C/C++ 为中心的构建系统中。

该过程包括

  1. 将 Swift 代码单独编译为静态库(.a 文件)
  2. 创建 Objective-C 桥接,以暴露 Swift 功能
  3. 将编译后的 Swift 库与我们的 Node.js 插件链接
  4. 管理 Swift 运行时依赖项

这种两步编译过程确保了 Swift 的高级语言特性和运行时得到正确处理,同时仍允许我们通过 Node.js 的原生插件系统将功能暴露给 JavaScript。

让我们开始添加一个基本结构

binding.gyp
{
"targets": [{
"target_name": "swift_addon",
"conditions": [
['OS=="mac"', {
"sources": [
"src/swift_addon.mm",
"src/SwiftBridge.m",
"src/SwiftCode.swift"
],
"include_dirs": [
"<!@(node -p \"require('node-addon-api').include\")",
"include",
"build_swift"
],
"dependencies": [
"<!(node -p \"require('node-addon-api').gyp\")"
],
"libraries": [
"<(PRODUCT_DIR)/libSwiftCode.a"
],
"cflags!": [ "-fno-exceptions" ],
"cflags_cc!": [ "-fno-exceptions" ],
"xcode_settings": {
"GCC_ENABLE_CPP_EXCEPTIONS": "YES",
"CLANG_ENABLE_OBJC_ARC": "YES",
"SWIFT_OBJC_BRIDGING_HEADER": "include/SwiftBridge.h",
"SWIFT_VERSION": "5.0",
"SWIFT_OBJC_INTERFACE_HEADER_NAME": "swift_addon-Swift.h",
"MACOSX_DEPLOYMENT_TARGET": "11.0",
"OTHER_CFLAGS": [
"-ObjC++",
"-fobjc-arc"
],
"OTHER_LDFLAGS": [
"-Wl,-rpath,@loader_path",
"-Wl,-install_name,@rpath/libSwiftCode.a"
],
"HEADER_SEARCH_PATHS": [
"$(SRCROOT)/include",
"$(CONFIGURATION_BUILD_DIR)",
"$(SRCROOT)/build/Release",
"$(SRCROOT)/build_swift"
]
},
"actions": []
}]
]
}]
}

我们包含了 Objective-C++ 文件(sources),指定了必要的 macOS 框架,并设置了 C++ 异常和 ARC。我们还设置了各种 Xcode 标志

  • GCC_ENABLE_CPP_EXCEPTIONS:在原生代码中启用 C++ 异常处理。
  • CLANG_ENABLE_OBJC_ARC:为 Objective-C 内存管理启用自动引用计数。
  • SWIFT_OBJC_BRIDGING_HEADER:指定桥接 Swift 和 Objective-C 代码的头文件。
  • SWIFT_VERSION:将 Swift 语言版本设置为 5.0。
  • SWIFT_OBJC_INTERFACE_HEADER_NAME:命名自动生成的头文件,该文件将 Swift 代码暴露给 Objective-C。
  • MACOSX_DEPLOYMENT_TARGET:设置所需的最低 macOS 版本(11.0/Big Sur)。
  • OTHER_CFLAGS:其他编译器标志:-ObjC++ 指定 Objective-C++ 模式。-fobjc-arc 在编译器级别启用 ARC。

然后,使用 OTHER_LDFLAGS,我们设置链接器标志

  • -Wl,-rpath,@loader_path:设置库的运行时搜索路径
  • -Wl,-install_name,@rpath/libSwiftCode.a:配置库的安装名称
  • HEADER_SEARCH_PATHS:编译期间搜索头文件的目录。

你可能还会注意到,我们在 JSON 中添加了一个当前为空的 actions 数组。在下一步中,我们将编译 Swift。

配置 Swift 构建设置

我们将添加两个操作:一个用于编译我们的 Swift 代码(以便进行链接),另一个用于将其复制到一个文件夹以供使用。将上面的 actions 数组替换为以下 JSON

binding.gyp
{
// ...other code
"actions": [
{
"action_name": "build_swift",
"inputs": [
"src/SwiftCode.swift"
],
"outputs": [
"build_swift/libSwiftCode.a",
"build_swift/swift_addon-Swift.h"
],
"action": [
"swiftc",
"src/SwiftCode.swift",
"-emit-objc-header-path", "./build_swift/swift_addon-Swift.h",
"-emit-library", "-o", "./build_swift/libSwiftCode.a",
"-emit-module", "-module-name", "swift_addon",
"-module-link-name", "SwiftCode"
]
},
{
"action_name": "copy_swift_lib",
"inputs": [
"<(module_root_dir)/build_swift/libSwiftCode.a"
],
"outputs": [
"<(PRODUCT_DIR)/libSwiftCode.a"
],
"action": [
"sh",
"-c",
"cp -f <(module_root_dir)/build_swift/libSwiftCode.a <(PRODUCT_DIR)/libSwiftCode.a && install_name_tool -id @rpath/libSwiftCode.a <(PRODUCT_DIR)/libSwiftCode.a"
]
}
]
// ...other code
}

这些操作

  • 使用 swiftc 将 Swift 代码编译为静态库
  • 从 Swift 代码生成 Objective-C 头文件
  • 将编译后的 Swift 库复制到输出目录
  • 使用 install_name_tool 修复库路径,通过设置正确的安装名称来确保动态链接器可以在运行时找到该库

3) 创建 Objective-C 桥接头文件

我们需要设置一个桥梁,连接 Swift 代码和原生的 Node.js C++ 插件。让我们从在 include/SwiftBridge.h 中创建一个头文件开始

include/SwiftBridge.h
#ifndef SwiftBridge_h
#define SwiftBridge_h

#import <Foundation/Foundation.h>

@interface SwiftBridge : NSObject
+ (NSString*)helloWorld:(NSString*)input;
+ (void)helloGui;

+ (void)setTodoAddedCallback:(void(^)(NSString* todoJson))callback;
+ (void)setTodoUpdatedCallback:(void(^)(NSString* todoJson))callback;
+ (void)setTodoDeletedCallback:(void(^)(NSString* todoId))callback;
@end

#endif

此头文件定义了我们将用于桥接 Swift 代码和 Node.js 插件的 Objective-C 接口。它包括

  • 一个简单的 helloWorld 方法,它接受一个字符串输入并返回一个字符串
  • 一个 helloGui 方法,它将显示一个原生的 SwiftUI 界面
  • 用于设置待办事项操作(添加、更新、删除)回调的方法

4) 实现 Objective-C 桥接

现在,让我们在 src/SwiftBridge.m 中创建 Objective-C 桥接本身

src/SwiftBridge.m
#import "SwiftBridge.h"
#import "swift_addon-Swift.h"
#import <Foundation/Foundation.h>

@implementation SwiftBridge

static void (^todoAddedCallback)(NSString*);
static void (^todoUpdatedCallback)(NSString*);
static void (^todoDeletedCallback)(NSString*);

+ (NSString*)helloWorld:(NSString*)input {
return [SwiftCode helloWorld:input];
}

+ (void)helloGui {
[SwiftCode helloGui];
}

+ (void)setTodoAddedCallback:(void(^)(NSString*))callback {
todoAddedCallback = callback;
[SwiftCode setTodoAddedCallback:callback];
}

+ (void)setTodoUpdatedCallback:(void(^)(NSString*))callback {
todoUpdatedCallback = callback;
[SwiftCode setTodoUpdatedCallback:callback];
}

+ (void)setTodoDeletedCallback:(void(^)(NSString*))callback {
todoDeletedCallback = callback;
[SwiftCode setTodoDeletedCallback:callback];
}

@end

这个桥接

  • 导入 Swift 生成的头文件(swift_addon-Swift.h
  • 实现头文件中定义的方法
  • 简单地将调用转发给 Swift 代码
  • 将回调存储在静态变量中以供以后使用,从而允许它们在应用程序的整个生命周期中持久存在。这确保了当待办事项被添加、更新或删除时,可以随时调用 JavaScript 回调。

5) 实现 Swift 代码

现在,让我们在 src/SwiftCode.swift 中实现我们的 Objective-C 代码。这是我们将使用 SwiftUI 创建原生 macOS GUI 的地方。

为了使本教程更容易理解,我们将从基本结构开始,然后逐步添加功能。

配置基本结构

让我们从基本结构开始。这里,我们只是设置变量、一些基本的回调方法和一个简单的辅助方法,我们稍后将用它来将数据转换为准备好进入 JavaScript 世界的格式。

src/SwiftCode.swift
import Foundation
import SwiftUI

@objc
public class SwiftCode: NSObject {
private static var windowController: NSWindowController?
private static var todoAddedCallback: ((String) -> Void)?
private static var todoUpdatedCallback: ((String) -> Void)?
private static var todoDeletedCallback: ((String) -> Void)?

@objc
public static func helloWorld(_ input: String) -> String {
return "Hello from Swift! You said: \(input)"
}

@objc
public static func setTodoAddedCallback(_ callback: @escaping (String) -> Void) {
todoAddedCallback = callback
}

@objc
public static func setTodoUpdatedCallback(_ callback: @escaping (String) -> Void) {
todoUpdatedCallback = callback
}

@objc
public static func setTodoDeletedCallback(_ callback: @escaping (String) -> Void) {
todoDeletedCallback = callback
}

private static func encodeToJson<T: Encodable>(_ item: T) -> String? {
let encoder = JSONEncoder()

// Encode date as milliseconds since 1970, which is what the JS side expects
encoder.dateEncodingStrategy = .custom { date, encoder in
let milliseconds = Int64(date.timeIntervalSince1970 * 1000)
var container = encoder.singleValueContainer()
try container.encode(milliseconds)
}

guard let jsonData = try? encoder.encode(item),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return nil
}
return jsonString
}

// More code to follow...
}

我们 Swift 代码的第一部分

  1. 声明一个带有 @objc 属性的类,使其可以从 Objective-C 访问
  2. 实现 helloWorld 方法
  3. 添加待办事项操作的回调设置器
  4. 包含一个辅助方法,用于将 Swift 对象编码为 JSON 字符串

实现 helloGui()

让我们继续 helloGui 方法和 SwiftUI 实现。这是我们开始在屏幕上添加用户界面元素的地方。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
// Other code...

@objc
public static func helloGui() -> Void {
let contentView = NSHostingView(rootView: ContentView(
onTodoAdded: { todo in
if let jsonString = encodeToJson(todo) {
todoAddedCallback?(jsonString)
}
},
onTodoUpdated: { todo in
if let jsonString = encodeToJson(todo) {
todoUpdatedCallback?(jsonString)
}
},
onTodoDeleted: { todoId in
todoDeletedCallback?(todoId.uuidString)
}
))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)

window.title = "Todo List"
window.contentView = contentView
window.center()

windowController = NSWindowController(window: window)
windowController?.showWindow(nil)

NSApp.activate(ignoringOtherApps: true)
}
}

这个 helloGui 方法

  1. 创建了一个托管在 NSHostingView 中的 SwiftUI 视图。这是一个关键的桥接组件,允许在 AppKit 应用程序中使用 SwiftUI 视图。NSHostingView 充当一个容器,它包装我们的 SwiftUI ContentView 并处理 SwiftUI 的声明式 UI 系统与 AppKit 的命令式 UI 系统之间的转换。这使我们能够利用 SwiftUI 的现代 UI 框架,同时仍然与传统的 macOS 窗口管理系统集成。
  2. 设置回调以在待办事项发生变化时通知 JavaScript。我们将在稍后设置实际的回调,现在我们只会调用它们(如果存在)。
  3. 创建并显示一个原生的 macOS 窗口。
  4. 激活应用程序以将窗口带到前面。

实现待办事项

接下来,我们将定义一个带有 ID、文本和日期的 TodoItem 模型。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
// Other code...

private struct TodoItem: Identifiable, Codable {
let id: UUID
var text: String
var date: Date

init(id: UUID = UUID(), text: String, date: Date) {
self.id = id
self.text = text
self.date = date
}
}
}

实现视图

接下来,我们可以实现实际的视图。Swift 在这方面相当冗长,所以下面的代码如果你是 Swift 新手可能会看起来很吓人。大量的代码掩盖了其简单性——我们只是在设置一些 UI 元素。这里没有什么是 Electron 特有的。

src/SwiftCode.swift
// Other code...

@objc
public class SwiftCode: NSObject {
// Other code...

private struct ContentView: View {
@State private var todos: [TodoItem] = []
@State private var newTodo: String = ""
@State private var newTodoDate: Date = Date()
@State private var editingTodo: UUID?
@State private var editedText: String = ""
@State private var editedDate: Date = Date()

let onTodoAdded: (TodoItem) -> Void
let onTodoUpdated: (TodoItem) -> Void
let onTodoDeleted: (UUID) -> Void

private func todoTextField(_ text: Binding<String>, placeholder: String, maxWidth: CGFloat? = nil) -> some View {
TextField(placeholder, text: text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: maxWidth ?? .infinity)
}

private func todoDatePicker(_ date: Binding<Date>) -> some View {
DatePicker("Due date", selection: date, displayedComponents: [.date])
.datePickerStyle(CompactDatePickerStyle())
.labelsHidden()
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
}

var body: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
todoTextField($newTodo, placeholder: "New todo")
todoDatePicker($newTodoDate)
Button(action: {
if !newTodo.isEmpty {
let todo = TodoItem(text: newTodo, date: newTodoDate)
todos.append(todo)
onTodoAdded(todo)
newTodo = ""
newTodoDate = Date()
}
}) {
Text("Add")
.frame(width: 50)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)

List {
ForEach(todos) { todo in
if editingTodo == todo.id {
HStack(spacing: 12) {
todoTextField($editedText, placeholder: "Edit todo", maxWidth: 250)
todoDatePicker($editedDate)
Button(action: {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
let updatedTodo = TodoItem(id: todo.id, text: editedText, date: editedDate)
todos[index] = updatedTodo
onTodoUpdated(updatedTodo)
editingTodo = nil
}
}) {
Text("Save")
.frame(width: 60)
}
}
.padding(.vertical, 4)
} else {
HStack(spacing: 12) {
Text(todo.text)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Text(todo.date.formatted(date: .abbreviated, time: .shortened))
.foregroundColor(.gray)
Button(action: {
editingTodo = todo.id
editedText = todo.text
editedDate = todo.date
}) {
Image(systemName: "pencil")
}
.buttonStyle(BorderlessButtonStyle())
Button(action: {
todos.removeAll(where: { $0.id == todo.id })
onTodoDeleted(todo.id)
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
}
.padding(.vertical, 4)
}
}
}
}
}
}
}

代码的这一部分

  • 创建一个 SwiftUI 视图,其中包含一个用于添加待办事项的表单,包含一个待办事项描述的文本字段、一个用于设置截止日期的日期选择器,以及一个“添加”按钮,该按钮会验证输入、创建一个新的 TodoItem、将其添加到本地数组、触发 onTodoAdded 回调以通知 JavaScript,然后重置下一个条目的输入字段。
  • 实现一个列表来显示待办事项,并支持编辑和删除功能
  • 当待办事项被添加、更新或删除时调用相应回调

最终文件应如下所示

src/SwiftCode.swift
import Foundation
import SwiftUI

@objc
public class SwiftCode: NSObject {
private static var windowController: NSWindowController?
private static var todoAddedCallback: ((String) -> Void)?
private static var todoUpdatedCallback: ((String) -> Void)?
private static var todoDeletedCallback: ((String) -> Void)?

@objc
public static func helloWorld(_ input: String) -> String {
return "Hello from Swift! You said: \(input)"
}

@objc
public static func setTodoAddedCallback(_ callback: @escaping (String) -> Void) {
todoAddedCallback = callback
}

@objc
public static func setTodoUpdatedCallback(_ callback: @escaping (String) -> Void) {
todoUpdatedCallback = callback
}

@objc
public static func setTodoDeletedCallback(_ callback: @escaping (String) -> Void) {
todoDeletedCallback = callback
}

private static func encodeToJson<T: Encodable>(_ item: T) -> String? {
let encoder = JSONEncoder()

// Encode date as milliseconds since 1970, which is what the JS side expects
encoder.dateEncodingStrategy = .custom { date, encoder in
let milliseconds = Int64(date.timeIntervalSince1970 * 1000)
var container = encoder.singleValueContainer()
try container.encode(milliseconds)
}

guard let jsonData = try? encoder.encode(item),
let jsonString = String(data: jsonData, encoding: .utf8) else {
return nil
}
return jsonString
}

@objc
public static func helloGui() -> Void {
let contentView = NSHostingView(rootView: ContentView(
onTodoAdded: { todo in
if let jsonString = encodeToJson(todo) {
todoAddedCallback?(jsonString)
}
},
onTodoUpdated: { todo in
if let jsonString = encodeToJson(todo) {
todoUpdatedCallback?(jsonString)
}
},
onTodoDeleted: { todoId in
todoDeletedCallback?(todoId.uuidString)
}
))
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 500),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)

window.title = "Todo List"
window.contentView = contentView
window.center()

windowController = NSWindowController(window: window)
windowController?.showWindow(nil)

NSApp.activate(ignoringOtherApps: true)
}

private struct TodoItem: Identifiable, Codable {
let id: UUID
var text: String
var date: Date

init(id: UUID = UUID(), text: String, date: Date) {
self.id = id
self.text = text
self.date = date
}
}

private struct ContentView: View {
@State private var todos: [TodoItem] = []
@State private var newTodo: String = ""
@State private var newTodoDate: Date = Date()
@State private var editingTodo: UUID?
@State private var editedText: String = ""
@State private var editedDate: Date = Date()

let onTodoAdded: (TodoItem) -> Void
let onTodoUpdated: (TodoItem) -> Void
let onTodoDeleted: (UUID) -> Void

private func todoTextField(_ text: Binding<String>, placeholder: String, maxWidth: CGFloat? = nil) -> some View {
TextField(placeholder, text: text)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: maxWidth ?? .infinity)
}

private func todoDatePicker(_ date: Binding<Date>) -> some View {
DatePicker("Due date", selection: date, displayedComponents: [.date])
.datePickerStyle(CompactDatePickerStyle())
.labelsHidden()
.frame(width: 100)
.textFieldStyle(RoundedBorderTextFieldStyle())
}

var body: some View {
VStack(spacing: 16) {
HStack(spacing: 12) {
todoTextField($newTodo, placeholder: "New todo")
todoDatePicker($newTodoDate)
Button(action: {
if !newTodo.isEmpty {
let todo = TodoItem(text: newTodo, date: newTodoDate)
todos.append(todo)
onTodoAdded(todo)
newTodo = ""
newTodoDate = Date()
}
}) {
Text("Add")
.frame(width: 50)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)

List {
ForEach(todos) { todo in
if editingTodo == todo.id {
HStack(spacing: 12) {
todoTextField($editedText, placeholder: "Edit todo", maxWidth: 250)
todoDatePicker($editedDate)
Button(action: {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
let updatedTodo = TodoItem(id: todo.id, text: editedText, date: editedDate)
todos[index] = updatedTodo
onTodoUpdated(updatedTodo)
editingTodo = nil
}
}) {
Text("Save")
.frame(width: 60)
}
}
.padding(.vertical, 4)
} else {
HStack(spacing: 12) {
Text(todo.text)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
Text(todo.date.formatted(date: .abbreviated, time: .shortened))
.foregroundColor(.gray)
Button(action: {
editingTodo = todo.id
editedText = todo.text
editedDate = todo.date
}) {
Image(systemName: "pencil")
}
.buttonStyle(BorderlessButtonStyle())
Button(action: {
todos.removeAll(where: { $0.id == todo.id })
onTodoDeleted(todo.id)
}) {
Image(systemName: "trash")
.foregroundColor(.red)
}
.buttonStyle(BorderlessButtonStyle())
}
.padding(.vertical, 4)
}
}
}
}
}
}
}

6) 创建 Node.js 插件桥接

我们现在有了工作的 Objective-C 代码,它又能够调用工作的 Swift 代码。为了确保它能够从 JavaScript 世界中安全、正确地调用,我们需要构建一个 Objective-C 和 C++ 之间的桥梁,我们可以用 Objective-C++ 来完成。我们将在 src/swift_addon.mm 中这样做。

src/swift_addon.mm
#import <Foundation/Foundation.h>
#import "SwiftBridge.h"
#include <napi.h>

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

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

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

// More code to follow...

这第一部分

  1. 定义一个继承自 Napi::ObjectWrap 的 C++ 类
  2. 创建一个静态 Init 方法来将我们的类注册到 Node.js
  3. 定义三个方法:helloWorldhelloGuion

回调机制

接下来,让我们实现回调机制

src/swift_addon.mm
// Previous code...

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

SwiftAddon(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<SwiftAddon>(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_, "SwiftCallback"),
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<SwiftAddon*>(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;
}

这一部分

  1. 定义一个结构体来在线程之间传递数据
  2. 设置插件的构造函数
  3. 创建线程安全函数来处理来自 Swift 的回调

让我们继续设置 Swift 回调

src/swift_addon.mm
// Previous code...

auto makeCallback = [this](const char* eventType) {
return ^(NSString* payload) {
if (tsfn_ != nullptr) {
auto* data = new CallbackData{
eventType,
std::string([payload UTF8String]),
this
};
napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
}
};
};

[SwiftBridge setTodoAddedCallback:makeCallback("todoAdded")];
[SwiftBridge setTodoUpdatedCallback:makeCallback("todoUpdated")];
[SwiftBridge setTodoDeletedCallback:makeCallback("todoDeleted")];
}

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

这一部分

  1. 创建一个辅助函数来生成 Objective-C 块,这些块将用作 Swift 事件的回调。这个 lambda 函数 makeCallback 接受一个事件类型字符串并返回一个 Objective-C 块,该块捕获事件类型和有效负载。当 Swift 调用此块时,它会创建一个包含事件信息的 CallbackData 结构体,并将其传递给线程安全函数,该函数在 Swift 线程和 Node.js 事件循环之间安全地桥接。
  2. 设置精心构造的待办事项操作回调
  3. 实现析构函数以清理资源

实例方法

最后,让我们实现实例方法

src/swift_addon.mm
// Previous code...

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>();
NSString* nsInput = [NSString stringWithUTF8String:input.c_str()];
NSString* result = [SwiftBridge helloWorld:nsInput];

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

void HelloGui(const Napi::CallbackInfo& info) {
[SwiftBridge helloGui];
}

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::Object Init(Napi::Env env, Napi::Object exports) {
return SwiftAddon::Init(env, exports);
}

NODE_API_MODULE(swift_addon, Init)

这最后一部分做了几件事

  1. 代码定义了环境、事件发射器、回调存储和线程安全函数私有成员变量,这些变量对于插件的运行至关重要。
  2. HelloWorld 方法的实现接受来自 JavaScript 的字符串输入,将其传递给 Swift 代码,并将处理后的结果返回给 JavaScript 环境。
  3. HelloGui 方法的实现提供了一个简单的包装器,它调用 Swift UI 创建函数来显示原生的 macOS 窗口。
  4. On 方法的实现允许 JavaScript 代码注册将在原生 Swift 代码中发生特定事件时调用的回调函数。
  5. 代码设置了模块初始化过程,该过程将插件注册到 Node.js 并使其功能可供 JavaScript 使用。

最终完整的 src/swift_addon.mm 应如下所示

src/swift_addon.mm
#import <Foundation/Foundation.h>
#import "SwiftBridge.h"
#include <napi.h>

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

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

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

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

SwiftAddon(const Napi::CallbackInfo& info)
: Napi::ObjectWrap<SwiftAddon>(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_, "SwiftCallback"),
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<SwiftAddon*>(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;
}

auto makeCallback = [this](const char* eventType) {
return ^(NSString* payload) {
if (tsfn_ != nullptr) {
auto* data = new CallbackData{
eventType,
std::string([payload UTF8String]),
this
};
napi_call_threadsafe_function(tsfn_, data, napi_tsfn_blocking);
}
};
};

[SwiftBridge setTodoAddedCallback:makeCallback("todoAdded")];
[SwiftBridge setTodoUpdatedCallback:makeCallback("todoUpdated")];
[SwiftBridge setTodoDeletedCallback:makeCallback("todoDeleted")];
}

~SwiftAddon() {
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>();
NSString* nsInput = [NSString stringWithUTF8String:input.c_str()];
NSString* result = [SwiftBridge helloWorld:nsInput];

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

void HelloGui(const Napi::CallbackInfo& info) {
[SwiftBridge helloGui];
}

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::Object Init(Napi::Env env, Napi::Object exports) {
return SwiftAddon::Init(env, exports);
}

NODE_API_MODULE(swift_addon, Init)

6) 创建 JavaScript 包装器

你离成功非常近了!我们现在有了工作的 Objective-C、Swift 和线程安全的方式来将方法和事件暴露给 JavaScript。在最后一步中,让我们在 js/index.js 中创建一个 JavaScript 包装器,以提供更友好的 API

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

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

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

const native = require('bindings')('swift_addon')
this.addon = new native.SwiftAddon()

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 () {
this.addon.helloGui()
}

parse (payload) {
const parsed = JSON.parse(payload)

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

if (process.platform === 'darwin') {
module.exports = new SwiftAddon()
} else {
module.exports = {}
}

这个包装器

  1. 扩展 EventEmitter 以提供事件支持
  2. 检查我们是否在 macOS 上运行
  3. 加载原生插件
  4. 设置事件监听器并转发它们
  5. 为我们的函数提供干净的 API
  6. 解析 JSON 有效负载并将时间戳转换为 JavaScript Date 对象

7) 构建和测试插件

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

npm run build

请注意,你无法直接从 Node.js 调用此脚本,因为 Node.js 在 macOS 眼中并未设置“应用程序”。但 Electron 可以,所以你可以通过从 Electron 中要求并调用它来测试你的代码。

结论

你现在已经使用 Swift 和 SwiftUI 构建了一个完整的原生 Node.js macOS 插件。这为在你的 Electron 应用中构建更复杂的 macOS 特定功能奠定了基础,让你两全其美:Web 技术的便捷性与原生 macOS 代码的强大功能。

此处演示的方法允许你

  • 配置一个项目结构,该结构桥接 Swift、Objective-C 和 JavaScript
  • 使用 SwiftUI 实现 Swift 代码以实现原生 UI
  • 创建 Objective-C 桥接以连接 Swift 和 Node.js
  • 使用回调和事件设置双向通信
  • 配置自定义构建过程来编译 Swift 代码

有关使用 Swift 和 Swift 开发的更多信息,请参阅 Apple 的开发者文档