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 中使用 Node.js N-API 那样直接使用 Swift,但你可以使用 Objective-C++ 创建一个桥接,将 Swift 与 Electron 应用中的 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

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

要求

🌐 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 命令行工具(可以通过在终端运行 xcode-select --install 来安装)

1) 创建一个软件包

🌐 1) Creating a package

你可以重用我们在原生代码和 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"
}
}

2) 设置构建配置

🌐 2) Setting Up the Build Configuration

在我们其他关于其他本地语言的教程中,我们可以使用 node-gyp 来构建我们代码的全部内容。而在 Swift 中,情况就有点复杂:我们需要先构建,然后再链接我们的 Swift 代码。这是因为 Swift 有自己独特的编译模型和运行时要求,这些要求不能直接与以 C/C++ 为中心的 node-gyp 构建系统集成。

🌐 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 文件)
  2. 创建一个 Objective-C 桥接器,用于公开 Swift 功能。
  3. 将编译后的 Swift 库与我们的 Node.js 插件链接
  4. 管理 Swift 运行时依赖

这个两步编译进程确保 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++ 异常处理。
  • 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,我们设置链接器标志:

🌐 Then, with OTHER_LDFLAGS, we set Linker flags:

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

你可能还会注意到我们在 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 代码编译为静态库
  • 从 Swift 代码生成 Objective-C 头文件
  • 将编译后的 Swift 库复制到输出目录
  • 通过使用 install_name_tool 修复库路径,以确保动态链接器在运行时可以找到该库,并设置正确的安装名称

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

🌐 3) Creating the Objective-C Bridge Header

我们需要在 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

此头文件定义了我们将用于在 Swift 代码与 Node.js 插件之间进行桥接的 Objective-C 接口。它包括:

🌐 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 方法,接受一个字符串输入并返回一个字符串
  • 一个将显示原生 SwiftUI 界面的 helloGui 方法
  • 用于设置待办事项操作(添加、更新、删除)回调的方法

4)实现 Objective-C 桥接

🌐 4) Implementing the Objective-C Bridge

现在,让我们在 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
  • 实现标头中定义的方法
  • 将调用转发到 Swift 代码
  • 将回调存储在静态变量中以供以后使用,使它们能够在应用的整个生命周期中持续存在。这确保了在待办事项被添加、更新或删除时,JavaScript 回调可以随时被调用。

5) 实现 Swift 代码

🌐 5) Implementing the Swift Code

现在,让我们在 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 访问
  2. 实现 helloWorld 方法
  3. 为待办事项操作添加回调设置器
  4. 包含一个辅助方法,用于将 Swift 对象编码为 JSON 字符串

实现 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 窗口管理系统集成。
  2. 设置回调以在待办事项更改时通知 JavaScript。我们稍后会设置实际的回调,目前我们只会在有可用回调时调用它们。
  3. 创建并显示一个原生 macOS 窗口。
  4. 激活应用,将窗口置于最前端。

实现待办事项

🌐 Implementing the Todo Item

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

🌐 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,然后重置输入字段以便下一次输入。
  • 实现列表以显示具有编辑和删除功能的待办事项
  • 在添加、更新或删除待办事项时调用相应的回调

最终文件应如下所示:

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

6) 创建 Node.js 插件桥接

🌐 6) Creating the Node.js Addon Bridge

我们现在有可运行的 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++ 类
  2. 创建一个静态 Init 方法,用于在 Node.js 中注册我们的类
  3. 定义三个方法:helloWorldhelloGuion

回调机制

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

实例方法

🌐 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. 代码定义了插件运行所必需的环境变量、事件触发器、回调存储和线程安全函数的私有成员变量。
  2. HelloWorld 方法的实现从 JavaScript 中获取字符串输入,将其传递给 Swift 代码,然后将处理后的结果返回给 JavaScript 环境。
  3. HelloGui 方法的实现提供了一个简单的封装,它调用 Swift UI 创建函数来显示原生 macOS 窗口。
  4. On 方法的实现允许 JavaScript 代码注册回调函数,这些回调函数将在本地 Swift 代码中发生特定事件时被调用。
  5. 代码设置了模块初始化进程,该进程将插件注册到 Node.js,并使其功能可供 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)

6) 创建一个 JavaScript 封装器

🌐 6) Creating a JavaScript Wrapper

你已经非常接近了!我们现在有了可用的 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('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 = {}
}

此封装器:

🌐 This wrapper:

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

7)构建和测试插件

🌐 7) Building and Testing the Addon

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

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

npm run build

请注意,你不能直接从 Node.js 调用这个脚本,因为在 macOS 看来,Node.js 并没有设置一个“应用”。但是 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 的项目结构
  • 使用 SwiftUI 为原生 UI 实现 Swift 代码
  • 创建一个 Objective-C 桥接器,用于连接 Swift 和 Node.js。
  • 使用回调函数和事件设置双向通信
  • 配置自定义构建进程以编译 Swift 代码

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

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