在 Electron 应用中集成 macOS 访达扩展 (FinderSync) 的终极指南
本文档总结了将一个原生 macOS 访达扩展(.appex
)成功集成到 Electron 应用中所需的所有步骤、配置和关键代码。我们从最初的签名、公证失败,到最终实现"首次启动时引导用户开启"的良好体验,涵盖了整个流程。
第一步:Xcode 项目配置 (你的 .appex
扩展)
这是所有工作的基础。在你的原生扩展的 Xcode 项目中,必须确保以下配置正确:
-
Bundle ID: 为你的主应用和扩展设置清晰的 Bundle ID。
- 主应用 (Electron) : 例如
com.yourcompany.yourapp
- 扩展 (.appex) : 应该是主应用的子级,例如
com.yourcompany.yourapp.FinderExtension
- 主应用 (Electron) : 例如
-
Hardened Runtime (强化运行时) : 必须开启 。这是苹果公证的强制要求。在
Signing & Capabilities
选项卡中勾选它。 -
App Groups: 如果你的主应用和扩展需要共享数据,请配置 App Groups。
-
【关键经验】
Info.plist
的配置:- 最初的尝试 : 我们曾在扩展的
Info.plist
中添加了NSExtensionContainingAppBundleIdentifier
键来明确声明父应用。 - 对比"豆包"后的经验 : 我们发现,对于
electron-builder
这种第三方打包工具,这个"显式声明"有时会与签名过程中的"隐式关系"(通过文件结构和统一签名建立)产生冲突。 - 最终方案 : 从扩展的
Info.plist
中移除NSExtensionContainingAppBundleIdentifier
键 。我们完全依赖于文件结构(.appex
在Contents/Plugins
目录下)和后续的统一签名来建立父子关系,这种方式更稳健,不易出错。
- 最初的尝试 : 我们曾在扩展的
第二步:electron-builder.yml
核心配置
这是指导 electron-builder
如何打包你的应用的"总指挥部"。
yaml
# ... 其他配置
mac:
identity: "你的开发者签名身份 (Your Developer ID)"
hardenedRuntime: true
# 为主应用和扩展分别提供授权文件
entitlements: "build/entitlements.mac.app.plist"
entitlementsInherit: "build/entitlements.mac.extension.plist"//这个是影响所有子组件的,安情况而定,建议和主应用一样,
# 将编译好的 .appex 复制到打包后的应用中
extraFiles:
- from: "build/YourAppExtension.appex" # 替换成你实际的.appex文件名
to: "Plugins/YourAppExtension.appex"
filter: ["**/*"]
# 【重要】将我们编译的原生插件也打包进去
extraResources:
# 路径需要根据你的项目结构调整
- from: "native_addon/build/Release/addon.node"
to: "native/addon.node"
# ... 其他配置
配套的授权文件:
-
build/entitlements.mac.app.plist
(给主应用,不带沙箱)xml<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.cs.allow-jit</key><true/> <key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/> <key>com.apple.security.cs.disable-library-validation</key><true/> <!-- 其他主应用需要的权限... --> </dict> </plist>
-
build/entitlements.mac.extension.plist
(给扩展,必须带沙箱)xml<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>com.apple.security.app-sandbox</key><true/> <!-- 如果需要,加入 App Groups --> <key>com.apple.security.application-groups</key> <array> <string>group.com.yourcompany.yourapp</string> <!-- 替换成你的 App Group ID --> </array> </dict> </plist>
第三步:afterSign
钩子脚本 (签名与公证)
这是整个流程中最关键、最精妙的部分。它解决了所有签名和公证问题。
核心逻辑:
electron-builder
完成了第一次签名。- 我们的脚本介入,重签了扩展,但这破坏了主应用的签名。
- 所以,我们再次重签整个主应用,使其签名与内部所有文件(包括被我们修改过的扩展)重新匹配。
- 最后,将这个签名完美的应用提交公证。
build/notarize.js
(最终版)
ini
const { execSync } = require('child_process');
const { notarize } = require('@electron/notarize');
// ... 其他 require
module.exports = async function (context) {
const { appOutDir, packager } = context;
const appName = packager.appInfo.productFilename;
const appPath = path.join(appOutDir, `${appName}.app`);
const identity = packager.platformSpecificBuildOptions.identity;
// 步骤1: 重签 App Extension
console.log('▶ Step 1: Re-signing App Extension with correct entitlements...');
const appexPath = path.join(appPath, 'Contents/Plugins/YourAppExtension.appex'); // 替换成你实际的.appex文件名
const extensionEntitlementsPath = path.resolve(__dirname, 'entitlements.mac.extension.plist');
const cmdExtension = `codesign --sign "${identity}" --force --options runtime --entitlements "${extensionEntitlementsPath}" "${appexPath}"`;
execSync(cmdExtension);
// 步骤2: 重签整个主应用以修复签名链
console.log('▶ Step 2: Re-signing the main application...');
const appEntitlementsPath = path.resolve(__dirname, 'entitlements.mac.app.plist');
const cmdApp = `codesign --sign "${identity}" --force --options runtime --entitlements "${appEntitlementsPath}" "${appPath}"`;
execSync(cmdApp);
// 步骤3: 公证
console.log('▶ Step 3: Notarizing the application...');
await notarize({
appBundleId: packager.appInfo.id,
appPath: appPath,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASS,
teamId: process.env.APPLE_TEAM_ID,
tool: 'notarytool'
});
};
第四步:实现"首次启动时提示一次" (原生插件)
核心经验 : 我们无法,也不应该去尝试"静默开启"扩展。这是macOS的安全底线。所有专业应用(包括豆包)的"自动开启"体验,都是通过以最快、最友好的方式引导用户完成授权来实现的。
我们通过创建一个原生Node.js插件来调用macOS的API。
-
文件结构 : 在项目根目录下创建
native_addon
文件夹,包含以下文件。 -
FinderExtensionManager.h
: 定义接口。 -
FinderExtensionManager.m
(最终版) : 使用动态调用,避免SDK版本问题,非常稳健。ini#import "FinderExtensionManager.h" #import <FinderSync/FinderSync.h> @implementation FinderExtensionManager + (void)showPanel { // 【重要】将这里的 Bundle ID 替换成你自己的 .appex 扩展的真实 ID NSString *extensionBundleIdentifier = @"com.yourcompany.yourapp.FinderExtension"; FIFinderSyncController *controller = [FIFinderSyncController defaultController]; SEL selector = NSSelectorFromString(@"showPanelForEnablingExtension:"); if ([controller respondsToSelector:selector]) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Warc-performSelector-leaks" [controller performSelector:selector withObject:extensionBundleIdentifier]; #pragma clang diagnostic pop } } @end
-
addon.mm
: 使用 Objective-C++ (.mm
) 作为桥梁,连接C++和Objective-C。 -
binding.gyp
: 编译脚本,确保sources
中包含addon.mm
。
第五步:在 Electron 主进程中调用插件
这是将所有努力呈现给用户的最后一步。
-
安装依赖 :
npm install electron-store node-addon-api
和npm install --save-dev node-gyp
。 -
主进程代码 (例如
main.js
) :- 智能加载 : 创建一个函数,使用
app.isPackaged
判断环境,从不同路径加载.node
文件,并用try...catch
包裹,防止应用崩溃。 - 首次运行逻辑 : 使用
electron-store
检查一个一次性标记(如extensionPrompted
)。 - 触发调用 : 在主窗口的
did-finish-load
事件中,检查标记并调用原生插件。
- 智能加载 : 创建一个函数,使用
这个最终的方案,集合了我们今天所有的探索和经验,它安全、健壮、专业,并且能为用户提供与顶尖应用相媲美的流畅体验。