Skip to main content

原生代码和 Electron:Swift (macOS)

¥Native Code and Electron: Swift (macOS)

本教程基于 原生代码和 Electron 简介 构建,重点介绍如何使用 Swift 为 macOS 创建原生插件。

¥This tutorial builds on the general introduction to Native Code and Electron and focuses on creating a native addon for macOS using Swift.

Swift 是一种现代且强大的语言,专为安全性和性能而设计。虽然你不能像 Electron 那样直接将 Swift 与 Node.js N-API 结合使用,但你可以在 Electron 应用中使用 Objective-C++ 搭建桥接,将 Swift 与 JavaScript 连接起来。

¥Swift is a modern, powerful language designed for safety and performance. While you can't use Swift directly with the Node.js N-API as used by Electron, you can create a bridge using Objective-C++ to connect Swift with JavaScript in your Electron application.

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

¥To illustrate how you can embed native macOS code in your Electron app, we'll be building a basic native macOS GUI (using SwiftUI) that communicates with Electron's JavaScript.

本教程对于已经熟悉 Objective-C、Swift 和 SwiftUI 开发的用户非常实用。你应该了解一些基本概念,例如 Swift 语法、可选值、闭包、SwiftUI 视图、属性封装器以及 Objective-C/Swift 互操作机制,例如 @objc 属性和桥接头文件。

¥This tutorial will be most useful to those who already have some familiarity with Objective-C, Swift, and SwiftUI development. You should understand basic concepts like Swift syntax, optionals, closures, SwiftUI views, property wrappers, and the Objective-C/Swift interoperability mechanisms such as the @objc attribute and bridging headers.

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

¥[!NOTE] If you're not already familiar with these concepts, Apple's Swift Programming Language Guide, SwiftUI Documentation, and Swift and Objective-C Interoperability Guide are excellent starting points.

要求

¥Requirements

原生代码和 Electron 简介 一样,本教程假设你已安装 Node.js 和 npm,以及在 macOS 上编译原生代码所需的基本工具。你需要:

¥Just like our general introduction to Native Code and Electron, this tutorial assumes you have Node.js and npm installed, as well as the basic tools necessary for compiling native code on macOS. You'll need:

  • 已安装 Xcode(可从 Mac App Store 获取)

    ¥Xcode installed (available from the Mac App Store)

  • Xcode 命令行工具(可通过在终端中运行 xcode-select --install 进行安装)

    ¥Xcode Command Line Tools (can be installed by running xcode-select --install in Terminal)

*

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

¥You can re-use the package we created in our Native Code and Electron tutorial. This tutorial will not be repeating the steps described there. Let's first setup our basic addon folder structure:

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 应该如下所示:

¥Our package.json should look like this:

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"
}
}

*

在我们其他专注于其他原生语言的教程中,我们可以使用 node-gyp 来构建完整的代码。使用 Swift 时,事情会变得更加棘手:我们需要先构建,然后链接我们的 Swift 代码。这是因为 Swift 有其自己的编译模型和运行时要求,它们不能直接与 node-gyp 的 C/C++ 构建系统集成。

¥In our other tutorials focusing on other native languages, we could use node-gyp to build the entirety of our code. With Swift, things are a bit more tricky: We need to first build and then link our Swift code. This is because Swift has its own compilation model and runtime requirements that don't directly integrate with node-gyp's C/C++ focused build system.

该过程涉及:

¥The process involves:

  1. 将 Swift 代码单独编译为静态库(.a 文件)

    ¥Compiling Swift code separately into a static library (.a file)

  2. 创建一个 Objective-C 桥接器,用于公开 Swift 功能。

    ¥Creating an Objective-C bridge that exposes Swift functionality

  3. 将编译后的 Swift 库与我们的 Node.js 插件链接

    ¥Linking the compiled Swift library with our Node.js addon

  4. 管理 Swift 运行时依赖

    ¥Managing Swift runtime dependencies

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

¥This two-step compilation process ensures that Swift's advanced language features and runtime are properly handled while still allowing us to expose the functionality to JavaScript through Node.js's native addon system.

让我们先添加一个基本结构:

¥Let's start by adding a basic structure:

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 标志:

¥We include our Objective-C++ files (sources), specify the necessary macOS frameworks, and set up C++ exceptions and ARC. We also set various Xcode flags:

  • GCC_ENABLE_CPP_EXCEPTIONS:在原生代码中启用 C++ 异常处理。

    ¥GCC_ENABLE_CPP_EXCEPTIONS: Enables C++ exception handling in the native code.

  • CLANG_ENABLE_OBJC_ARC:启用 Objective-C 内存管理的自动引用计数。

    ¥CLANG_ENABLE_OBJC_ARC: Enables Automatic Reference Counting for Objective-C memory management.

  • SWIFT_OBJC_BRIDGING_HEADER:指定连接 Swift 和 Objective-C 代码的头文件。

    ¥SWIFT_OBJC_BRIDGING_HEADER: Specifies the header file that bridges Swift and Objective-C code.

  • SWIFT_VERSION:将 Swift 语言版本设置为 5.0。

    ¥SWIFT_VERSION: Sets the Swift language version to 5.0.

  • SWIFT_OBJC_INTERFACE_HEADER_NAME:命名自动生成的标头,该标头将 Swift 代码暴露给 Objective-C。

    ¥SWIFT_OBJC_INTERFACE_HEADER_NAME: Names the automatically generated header that exposes Swift code to Objective-C.

  • MACOSX_DEPLOYMENT_TARGET:设置所需的最低 macOS 版本 (11.0/Big Sur)。

    ¥MACOSX_DEPLOYMENT_TARGET: Sets the minimum macOS version (11.0/Big Sur) required.

  • OTHER_CFLAGS:其他编译器标志:-ObjC++ 指定 Objective-C++ 模式。-fobjc-arc 在编译器级别启用 ARC。

    ¥OTHER_CFLAGS: Additional compiler flags: -ObjC++ specifies Objective-C++ mode. -fobjc-arc enables ARC at the compiler level.

然后,使用 OTHER_LDFLAGS 设置 Linker 标志:

¥Then, with OTHER_LDFLAGS, we set Linker flags:

  • -Wl,-rpath,@loader_path:设置库的运行时搜索路径

    ¥-Wl,-rpath,@loader_path: Sets runtime search path for libraries

  • -Wl,-install_name,@rpath/libSwiftCode.a:配置库安装名称

    ¥-Wl,-install_name,@rpath/libSwiftCode.a: Configures library install name

  • HEADER_SEARCH_PATHS:编译期间用于搜索头文件的目录。

    ¥HEADER_SEARCH_PATHS: Directories to search for header files during compilation.

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

¥You might also notice that we added a currently empty actions array to the JSON. In the next step, we'll be compiling Swift.

设置 Swift 构建配置

¥Setting up the Swift Build Configuration

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

¥We'll add two actions: One to compile our Swift code (so that it can be linked) and another one to copy it to a folder to use. Replace the actions array above with the following 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
}

这些操作:

¥These actions:

  • 使用 swiftc 将 Swift 代码编译为静态库

    ¥Compile the Swift code to a static library using swiftc

  • 从 Swift 代码生成 Objective-C 头文件

    ¥Generate an Objective-C header from the Swift code

  • 将编译后的 Swift 库复制到输出目录

    ¥Copy the compiled Swift library to the output directory

  • 使用 install_name_tool 修复库路径,通过设置正确的安装名称,确保动态链接器能够在运行时找到该库。

    ¥Fix the library path with install_name_tool to ensure the dynamic linker can find the library at runtime by setting the correct install name

*

我们需要在 Swift 代码和原生 Node.js C++ 插件之间搭建桥接。让我们先在 include/SwiftBridge.h 中为桥接器创建一个头文件:

¥We'll need to setup a bridge between the Swift code and the native Node.js C++ addon. Let's start by creating a header file for the bridge in 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

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

¥This header defines the Objective-C interface that we'll use to bridge between our Swift code and the Node.js addon. It includes:

  • 一个简单的 helloWorld 方法,用于接受字符串输入并返回字符串

    ¥A simple helloWorld method that takes a string input and returns a string

  • 一个 helloGui 方法,用于显示原生 SwiftUI 界面

    ¥A helloGui method that will display a native SwiftUI interface

  • 用于设置待办事项操作(添加、更新、删除)回调的方法

    ¥Methods to set callbacks for todo operations (add, update, delete)

*

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

¥Now, let's create the Objective-C bridge itself in src/SwiftBridge.m:

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

此桥接:

¥This bridge:

  • 导入 Swift 生成的标头 (swift_addon-Swift.h)

    ¥Imports the Swift-generated header (swift_addon-Swift.h)

  • 实现标头中定义的方法

    ¥Implements the methods defined in our header

  • 将调用转发到 Swift 代码

    ¥Simply forwards calls to the Swift code

  • 将回调函数存储在静态变量中以供后续使用,从而使它们在应用的整个生命周期中持久存在。这确保了在添加、更新或删除待办事项时可以随时调用 JavaScript 回调。

    ¥Stores the callbacks for later use in static variables, allowing them to persist throughout the application's lifecycle. This ensures that the JavaScript callbacks can be invoked at any time when todo items are added, updated, or deleted.

*

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

¥Now, let's implement our Objective-C code in src/SwiftCode.swift. This is where we'll create our native macOS GUI using SwiftUI.

为了使本教程更易于理解,我们将从基本结构开始,逐步添加功能。 - 一步一步来。

¥To make this tutorial easier to follow, we'll start with the basic structure and add features incrementally - step by step.

设置基本结构

¥Setting Up the Basic Structure

让我们从基本结构开始。在这里,我们仅设置变量、一些基本的回调方法和一个简单的辅助方法,稍后我们将使用它们将数据转换为 JavaScript 可用的格式。

¥Let's start with the basic structure. Here, we're just setting up variables, some basic callback methods, and a simple helper method we'll use later to convert data into formats ready for the JavaScript world.

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 代码的第一部分:

¥This first part of our Swift code:

  1. 声明一个带有 @objc 属性的类,使其可以从 Objective-C 访问。

    ¥Declares a class with the @objc attribute, making it accessible from Objective-C

  2. 实现 helloWorld 方法

    ¥Implements the helloWorld method

  3. 为待办事项操作添加回调设置器

    ¥Adds callback setters for todo operations

  4. 包含一个辅助方法,用于将 Swift 对象编码为 JSON 字符串

    ¥Includes a helper method to encode Swift objects to JSON strings

实现 helloGui()

¥Implementing helloGui()

让我们继续 helloGui 方法和 SwiftUI 实现。从这里我们开始向屏幕添加用户界面元素。

¥Let's continue with the helloGui method and the SwiftUI implementation. This is where we start adding user interface elements to the screen.

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 方法:

¥This helloGui method:

  1. 创建一个托管在 NSHostingView 中的 SwiftUI 视图。这是一个至关重要的桥接组件,它允许在 AppKit 应用中使用 SwiftUI 视图。NSHostingView 充当一个容器,它封装了我们的 SwiftUI ContentView,并处理 SwiftUI 的声明式 UI 系统和 AppKit 的命令式 UI 系统之间的转换。这使我们能够利用 SwiftUI 的现代 UI 框架,同时仍然与传统的 macOS 窗口管理系统集成。

    ¥Creates a SwiftUI view hosted in an NSHostingView. This is a crucial bridging component that allows SwiftUI views to be used in AppKit applications. The NSHostingView acts as a container that wraps our SwiftUI ContentView and handles the translation between SwiftUI's declarative UI system and AppKit's imperative UI system. This enables us to leverage SwiftUI's modern UI framework while still integrating with the traditional macOS window management system.

  2. 设置回调函数,以便在待办事项发生变化时通知 JavaScript。我们稍后会设置实际的回调函数,现在我们只会在有回调函数可用时调用它们。

    ¥Sets up callbacks to notify JavaScript when todo items change. We'll setup the actual callbacks later, for now we'll just call them if one is available.

  3. 创建并显示一个原生 macOS 窗口。

    ¥Creates and displays a native macOS window.

  4. 激活应用,将窗口置于最前端。

    ¥Activates the app to bring the window to the front.

实现待办事项

¥Implementing the Todo Item

接下来,我们将定义一个 TodoItem 模型,其中包含 ID、文本和日期。

¥Next, we'll define a TodoItem model with an ID, text, and date.

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
}
}
}

实现视图

¥Implementing the View

接下来,我们可以实现实际的视图。Swift 的代码在这里相当冗长,因此如果你是 Swift 新手,下面的代码可能会让你感到畏惧。过多的代码行掩盖了其简洁性 - 我们只是在设置一些 UI 元素。这里没有任何内容是 Electron 特有的。

¥Next, we can implement the actual view. Swift is fairly verbose here, so the code below might look scary if you're new to Swift. The many lines of code obfuscate the simplicity in it - we're just setting up some UI elements. Nothing here is specific to 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)
}
}
}
}
}
}
}

此部分代码:

¥This part of the code:

  • 创建一个包含表单的 SwiftUI 视图,用于添加新的待办事项,该表单包含一个用于输入待办事项描述的文本字段、一个用于设置截止日期的日期选择器以及一个用于验证输入的“添加”按钮,创建一个新的 TodoItem,将其添加到本地数组,触发 onTodoAdded 回调以通知 JavaScript,然后重置输入字段以进行下一个条目。

    ¥Creates a SwiftUI view with a form to add new todos, featuring a text field for the todo description, a date picker for setting due dates, and an Add button that validates input, creates a new TodoItem, adds it to the local array, triggers the onTodoAdded callback to notify JavaScript, and then resets the input fields for the next entry.

  • 实现列表以显示具有编辑和删除功能的待办事项

    ¥Implements a list to display todos with edit and delete capabilities

  • 在添加、更新或删除待办事项时调用相应的回调

    ¥Calls the appropriate callbacks when todos are added, updated, or deleted

最终文件应如下所示:

¥The final file should look as follows:

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)
}
}
}
}
}
}
}

*

我们现在有了可以运行的 Objective-C 代码,它们可以调用可以运行的 Swift 代码。为了确保 JavaScript 能够安全正确地调用它,我们需要在 Objective-C 和 C++ 之间搭建桥梁,而 Objective-C++ 可以做到这一点。我们将在 src/swift_addon.mm 中执行此操作。

¥We now have working Objective-C code, which in turn is able to call working Swift code. To make sure it can be safely and properly called from the JavaScript world, we need to build a bridge between Objective-C and C++, which we can do with Objective-C++. We'll do that in 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...

此第一部分:

¥This first part:

  1. 定义一个继承自 Napi::ObjectWrap 的 C++ 类。

    ¥Defines a C++ class that inherits from Napi::ObjectWrap

  2. 创建一个静态 Init 方法,用于将我们的类注册到 Node.js。

    ¥Creates a static Init method to register our class with Node.js

  3. 定义三个方法:helloWorldhelloGuion

    ¥Defines three methods: helloWorld, helloGui, and on

回调机制

¥Callback Mechanism

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

¥Next, let's implement the callback mechanism:

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;
}

此部分:

¥This part:

  1. 定义一个结构体,用于在线程之间传递数据。

    ¥Defines a struct to pass data between threads

  2. 为我们的插件设置构造函数

    ¥Sets up a constructor for our addon

  3. 创建一个线程安全函数,用于处理来自 Swift 的回调。

    ¥Creates a threadsafe function to handle callbacks from Swift

让我们继续设置 Swift 回调:

¥Let's continue with setting up the Swift callbacks:

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;
}
}

此部分:

¥This part:

  1. 创建一个辅助函数,用于生成将用作 Swift 事件回调的 Objective-C 块。这个 lambda 函数 makeCallback 接受一个事件类型字符串,并返回一个捕获事件类型和有效负载的 Objective-C 块。当 Swift 调用此代码块时,它会创建一个包含事件信息的 CallbackData 结构体,并将其传递给线程安全函数,该函数可以安全地在 Swift 线程和 Node.js 事件循环之间搭建桥接。

    ¥Creates a helper function to generate Objective-C blocks that will be used as callbacks for Swift events. This lambda function makeCallback takes an event type string and returns an Objective-C block that captures the event type and payload. When Swift calls this block, it creates a CallbackData structure with the event information and passes it to the threadsafe function, which safely bridges between Swift's thread and Node.js's event loop.

  2. 设置精心构造的待办事项操作回调函数

    ¥Sets up the carefully constructed callbacks for todo operations

  3. 实现析构函数以清理资源

    ¥Implements a destructor to clean up resources

实例方法

¥Instance Methods

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

¥Finally, let's implement the instance methods:

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)

这最后一部分做了几件事:

¥This final part does multiple things:

  1. 代码定义了插件运行所必需的环境变量、事件触发器、回调存储和线程安全函数的私有成员变量。

    ¥The code defines private member variables for the environment, event emitter, callback storage, and thread-safe function that are essential for the addon's operation.

  2. HelloWorld 方法的实现从 JavaScript 中获取字符串输入,将其传递给 Swift 代码,然后将处理后的结果返回给 JavaScript 环境。

    ¥The HelloWorld method implementation takes a string input from JavaScript, passes it to the Swift code, and returns the processed result back to the JavaScript environment.

  3. HelloGui 方法的实现提供了一个简单的封装器,它调用 Swift UI 创建函数来显示原生 macOS 窗口。

    ¥The HelloGui method implementation provides a simple wrapper that calls the Swift UI creation function to display the native macOS window.

  4. On 方法的实现允许 JavaScript 代码注册回调函数,当原生 Swift 代码中发生特定事件时,这些回调函数将被调用。

    ¥The On method implementation allows JavaScript code to register callback functions that will be invoked when specific events occur in the native Swift code.

  5. 代码设置了模块初始化过程,该过程将插件注册到 Node.js,并使其功能可供 JavaScript 使用。

    ¥The code sets up the module initialization process that registers the addon with Node.js and makes its functionality available to JavaScript.

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

¥The final and full src/swift_addon.mm should look like:

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)

*

你离目标很近了!我们现在有了可以运行的 Objective-C、Swift 和线程安全的方式将方法和事件暴露给 JavaScript。在这最后一步,让我们在 js/index.js 中创建一个 JavaScript 封装器,以提供更友好的 API:

¥You're so close! We now have working Objective-C, Swift, and thread-safe ways to expose methods and events to JavaScript. In this final step, let's create a JavaScript wrapper in js/index.js to provide a more friendly API:

js/index.js
const EventEmitter = require('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 = {}
}

此封装器:

¥This wrapper:

  1. 扩展 EventEmitter 以提供事件支持

    ¥Extends EventEmitter to provide event support

  2. 检查是否在 macOS 上运行

    ¥Checks if we're running on macOS

  3. 加载原生插件

    ¥Loads the native addon

  4. 设置事件监听器并转发它们

    ¥Sets up event listeners and forwards them

  5. 为我们的函数提供简洁的 API

    ¥Provides a clean API for our functions

  6. 解析 JSON 负载并将时间戳转换为 JavaScript Date 对象

    ¥Parses JSON payloads and converts timestamps to JavaScript Date objects

*

所有文件就绪后,即可构建插件:

¥With all files in place, you can build the addon:

npm run build

请注意,你无法直接从 Node.js 调用此脚本,因为 Node.js 在 macOS 中不会设置 "app"。Electron 实现了此功能,因此你可以通过在 Electron 中引用并调用它来测试你的代码。

¥Please note that you cannot call this script from Node.js directly, since Node.js doesn't set up an "app" in the eyes of macOS. Electron does though, so you can test your code by requiring and calling it from Electron.

结论

¥Conclusion

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

¥You've now built a complete native Node.js addon for macOS using Swift and SwiftUI. This provides a foundation for building more complex macOS-specific features in your Electron apps, giving you the best of both worlds: the ease of web technologies with the power of native macOS code.

此处演示的方法允许你:

¥The approach demonstrated here allows you to:

  • 设置连接 Swift、Objective-C 和 JavaScript 的项目结构

    ¥Setting up a project structure that bridges Swift, Objective-C, and JavaScript

  • 使用 SwiftUI 为原生 UI 实现 Swift 代码

    ¥Implementing Swift code with SwiftUI for native UI

  • 创建一个 Objective-C 桥接器,用于连接 Swift 和 Node.js。

    ¥Creating an Objective-C bridge to connect Swift with Node.js

  • 使用回调函数和事件设置双向通信

    ¥Setting up bidirectional communication using callbacks and events

  • 配置自定义构建流程以编译 Swift 代码

    ¥Configuring a custom build process to compile Swift code

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

¥For more information on developing with Swift and Swift, refer to Apple's developer documentation: