本次提交完成 macOS 端多项关键优化与修复: 1. 修复启动期竞态闪退问题,通过前移窗口属性初始化、串行化特效调用、跳过首次同步实现稳定启动 2. 实现系统菜单栏多语言本地化,支持中英日韩繁五种语言,软件内切换语言可同步更新菜单栏 3. 移除视图菜单中重复的全屏按钮,统一窗口标题栏逻辑 4. 新增 macOS App Store 打包配置与本地化资源
570 lines
23 KiB
Swift
570 lines
23 KiB
Swift
/// ============================================================
|
||
/// 闲言APP — macOS 主窗口
|
||
/// 创建时间: 2026-06-02
|
||
/// 更新时间: 2026-06-24
|
||
/// 作用: 主窗口初始化,注册 apps.xy.xianyan/macos MethodChannel
|
||
/// 上次更新: 实现菜单栏语言切换 — 新增 menuItemIds 静态数组(XIB objectId 列表)、
|
||
/// assignMenuItemIdentifiers(为每个 menuItem 设置 identifier)、
|
||
/// setMenuLanguage(根据 languageId 加载对应 lproj/MainMenu.strings
|
||
/// 并递归更新 NSApp.mainMenu 所有菜单项标题)。
|
||
/// 修复软件内切换语言后系统菜单栏不跟随切换的问题。
|
||
/// 同时保留先前修复:在 awakeFromNib 提前调用
|
||
/// MainFlutterWindowManipulator.start(mainFlutterWindow: self)
|
||
/// 避免 reset() 走 start(nil) 分支触发竞态崩溃;
|
||
/// contentViewController 由 FlutterViewController 改为
|
||
/// MacOSWindowUtilsViewController 包装,使原生侧 setMaterial/
|
||
/// setNSVisualEffectViewState 等方法的类型转换成功。
|
||
/// 通过 setExtraFlag 保留 fullSizeContentView + titlebarAppearsTransparent
|
||
/// 实现标题栏融合(红黄绿灯由 Flutter 侧 DesktopWindowTitleBar 自绘)
|
||
/// ============================================================
|
||
|
||
import Cocoa
|
||
import FlutterMacOS
|
||
import macos_window_utils
|
||
|
||
class MainFlutterWindow: NSWindow, NSTouchBarDelegate {
|
||
// ============================================================
|
||
// MARK: - 属性
|
||
// ============================================================
|
||
|
||
/// 平台通道引用,用于 Touch Bar 按钮回调 + 全屏退出事件通知
|
||
private var platformChannel: FlutterMethodChannel?
|
||
|
||
/// Touch Bar 按钮配置 [{label: "加粗", action: "bold"}, ...]
|
||
private var touchBarItems: [[String: String]] = []
|
||
|
||
override func awakeFromNib() {
|
||
let flutterViewController = FlutterViewController()
|
||
let windowFrame = self.frame
|
||
|
||
// 关键修复:使用 MacOSWindowUtilsViewController 包装 FlutterViewController
|
||
// macos_window_utils 插件的原生方法(setMaterial/setNSVisualEffectViewState/
|
||
// addVisualEffectSubview 等)内部执行
|
||
// `contentViewController as! MacOSWindowUtilsViewController`,
|
||
// 若直接使用 FlutterViewController 作为 contentViewController 会触发
|
||
// "Could not cast value of type 'FlutterViewController' to
|
||
// 'MacOSWindowUtilsViewController'" 崩溃。
|
||
// MacOSWindowUtilsViewController.loadView() 会创建 NSVisualEffectView 作为
|
||
// 根视图并 addChild(flutterViewController),是侧边栏毛玻璃等特效的载体。
|
||
let macOSWindowUtilsViewController = MacOSWindowUtilsViewController(
|
||
flutterViewController: flutterViewController
|
||
)
|
||
self.contentViewController = macOSWindowUtilsViewController
|
||
self.setFrame(windowFrame, display: true)
|
||
|
||
// 在 Flutter 引擎启动前预设窗口透明属性,消除 Impeller 渲染期竞态
|
||
// 根因:macos_window_utils 的 setWindowBackgroundColorToClear() 在渲染期间调用
|
||
// [NSWindow setBackgroundColor:] → NSThemeFrame._updateBackdropView → removeFromSuperview,
|
||
// 与 Impeller raster 线程的 SetupRenderPass 产生竞态,导致空指针崩溃(Release 模式时序更紧)。
|
||
// 修复:将不变属性前移到引擎启动前设置,Dart 侧不再在渲染期重复调用。
|
||
self.isOpaque = false
|
||
self.backgroundColor = NSColor.clear
|
||
self.titlebarAppearsTransparent = true
|
||
self.titleVisibility = .hidden
|
||
if !self.styleMask.contains(.fullSizeContentView) {
|
||
self.styleMask.insert(.fullSizeContentView)
|
||
}
|
||
|
||
// 关键修复:提前调用 MainFlutterWindowManipulator.start(mainFlutterWindow: self)
|
||
// 让 macos_window_utils 插件的 mainFlutterWindow 静态属性先被设置。
|
||
// 否则后续 Dart 侧 WindowManipulator.initialize() → 原生 reset() 会因
|
||
// mainFlutterWindow == nil 走 start(nil) 分支,触发
|
||
// setWindowBackgroundColorToDefaultColor() → [NSWindow setBackgroundColor:]
|
||
// → NSThemeFrame._updateBackdropView → removeFromSuperview,
|
||
// 与 Impeller raster 线程的 SetupRenderPass 产生竞态导致 EXC_BAD_ACCESS 崩溃。
|
||
//
|
||
// 注意:start() 内部会调用 showTitle()/makeTitlebarOpaque()/disableFullSizeContentView()/
|
||
// setWindowBackgroundColorToDefaultColor() 覆盖上述预设属性,因此必须在 start() 之后
|
||
// 重新设置透明属性。此时 Flutter 引擎尚未启动,Impeller raster 线程未运行,
|
||
// [NSWindow setBackgroundColor:] 不会与渲染线程竞态,安全无崩溃。
|
||
//
|
||
// 由于此处传入 mainFlutterWindow: self,start() 内部 isProvidedWindow=true,
|
||
// 不会调用 configureMainFlutterWindow()(该方法会再次包装 contentViewController,
|
||
// 但因已是 MacOSWindowUtilsViewController,重复包装会导致 FlutterViewController
|
||
// 被嵌套两层,引发布局异常)。当前方案在上方已正确包装,无需 configure。
|
||
MainFlutterWindowManipulator.start(mainFlutterWindow: self)
|
||
|
||
// start() 覆盖后重新设置透明属性(引擎启动前,无竞态风险)
|
||
self.isOpaque = false
|
||
self.backgroundColor = NSColor.clear
|
||
self.titlebarAppearsTransparent = true
|
||
self.titleVisibility = .hidden
|
||
if !self.styleMask.contains(.fullSizeContentView) {
|
||
self.styleMask.insert(.fullSizeContentView)
|
||
}
|
||
|
||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||
|
||
// 在 Flutter 引擎启动前注册自定义 MethodChannel,避免 MissingPluginException
|
||
registerPlatformChannel(controller: flutterViewController)
|
||
|
||
// 监听全屏退出:修复全屏退出后窗口背景变黑的问题
|
||
// 全屏模式下 macOS 强制窗口不透明,退出后需重新设置背景透明
|
||
NotificationCenter.default.addObserver(
|
||
self,
|
||
selector: #selector(handleFullScreenExit(_:)),
|
||
name: NSWindow.didExitFullScreenNotification,
|
||
object: self
|
||
)
|
||
|
||
// 为 mainMenu 中每个 menuItem 设置 identifier(XIB objectId)
|
||
// 以便 setMenuLanguage 能根据 identifier 查找 .strings 中的本地化标题
|
||
// 必须在 NSApp.mainMenu 从 XIB 加载后调用(awakeFromNib 时 mainMenu 已存在)
|
||
self.assignMenuItemIdentifiers()
|
||
|
||
super.awakeFromNib()
|
||
}
|
||
|
||
deinit {
|
||
NotificationCenter.default.removeObserver(self)
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - 全屏退出处理
|
||
// ============================================================
|
||
|
||
/// 全屏退出后重新应用窗口透明背景
|
||
///
|
||
/// 修复问题:通过「视图 → Enter Full Screen」进入原生全屏后,
|
||
/// 点击绿灯退出全屏(选择小窗),窗口背景变为黑色而非透明。
|
||
///
|
||
/// 根因:macos_window_utils 的 `setWindowBackgroundColorToClear()` 设置了
|
||
/// `isOpaque=false` + `backgroundColor=NSColor.clear`。但全屏模式下 macOS
|
||
/// 强制窗口不透明(`isOpaque=true`, `backgroundColor=黑色`),
|
||
/// 退出全屏后这些属性未自动恢复,导致窗口外侧显示黑色而非桌面。
|
||
/// 同时 `titlebarAppearsTransparent` 也可能被系统重置。
|
||
///
|
||
/// 修复:监听 `NSWindow.didExitFullScreenNotification`,
|
||
/// 退出全屏后立即重新设置 `isOpaque=false` + `backgroundColor=clear` +
|
||
/// `titlebarAppearsTransparent=true`,并通过 MethodChannel 通知 Dart 侧
|
||
/// 重新应用完整窗口特效(NSVisualEffectView 毛玻璃等)。
|
||
@objc private func handleFullScreenExit(_ notification: Notification) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
guard let self = self else { return }
|
||
// 重新设置窗口背景透明(让 NSVisualEffectView 透出)
|
||
self.isOpaque = false
|
||
self.backgroundColor = NSColor.clear
|
||
// 重新设置标题栏透明(由 Flutter 侧自绘标题栏)
|
||
self.titlebarAppearsTransparent = true
|
||
// 触发窗口重绘
|
||
self.contentView?.needsDisplay = true
|
||
// 通知 Dart 侧重新应用完整窗口特效(NSVisualEffectView 等)
|
||
self.platformChannel?.invokeMethod("onFullScreenExited", arguments: nil)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - MethodChannel 注册
|
||
// ============================================================
|
||
|
||
/// 注册 apps.xy.xianyan/macos 平台通道,对接 Dart 端 MacosPlatformService
|
||
private func registerPlatformChannel(controller: FlutterViewController) {
|
||
let channel = FlutterMethodChannel(
|
||
name: "apps.xy.xianyan/macos",
|
||
binaryMessenger: controller.engine.binaryMessenger
|
||
)
|
||
platformChannel = channel
|
||
|
||
channel.setMethodCallHandler { [weak self] (call, result) in
|
||
guard let self = self else {
|
||
result(FlutterError(code: "window_deallocated",
|
||
message: "MainFlutterWindow 已释放",
|
||
details: nil))
|
||
return
|
||
}
|
||
|
||
switch call.method {
|
||
// ---------- 主题同步 ----------
|
||
case "setDarkMode":
|
||
let isDark = (call.arguments as? Bool) ?? false
|
||
self.setDarkMode(isDark: isDark)
|
||
result(nil)
|
||
|
||
case "getSystemAppearance":
|
||
result(self.getSystemAppearance())
|
||
|
||
// ---------- 窗口管理 ----------
|
||
case "setWindowTitle":
|
||
let title = (call.arguments as? String) ?? ""
|
||
self.title = title
|
||
result(nil)
|
||
|
||
case "setTitleBarTransparent":
|
||
let transparent = (call.arguments as? Bool) ?? false
|
||
self.titlebarAppearsTransparent = transparent
|
||
result(nil)
|
||
|
||
case "setTitleBarStyle":
|
||
let style = (call.arguments as? String) ?? "auto"
|
||
self.setTitleBarStyle(style: style)
|
||
result(nil)
|
||
|
||
case "setToolbarVisible":
|
||
let visible = (call.arguments as? Bool) ?? true
|
||
if visible {
|
||
self.toolbar?.isVisible = true
|
||
} else {
|
||
self.toolbar?.isVisible = false
|
||
}
|
||
result(nil)
|
||
|
||
case "setFullscreen":
|
||
let fullscreen = (call.arguments as? Bool) ?? false
|
||
self.setFullscreen(fullscreen: fullscreen)
|
||
result(nil)
|
||
|
||
case "isFullscreen":
|
||
result(self.styleMask.contains(.fullScreen))
|
||
|
||
case "setMinSize":
|
||
guard let args = call.arguments as? [String: Any],
|
||
let width = args["width"] as? Double,
|
||
let height = args["height"] as? Double else {
|
||
result(FlutterError(code: "invalid_args",
|
||
message: "setMinSize 需要 {width, height}",
|
||
details: nil))
|
||
return
|
||
}
|
||
self.minSize = NSSize(width: width, height: height)
|
||
result(nil)
|
||
|
||
// ---------- 系统集成 ----------
|
||
case "performHapticFeedback":
|
||
let type = (call.arguments as? String) ?? "generic"
|
||
self.performHapticFeedback(type: type)
|
||
result(nil)
|
||
|
||
// ---------- Touch Bar 支持 ----------
|
||
case "setTouchBarItems":
|
||
let args = call.arguments as? [String: Any] ?? [:]
|
||
let rawItems = args["items"] as? [[String: Any]] ?? []
|
||
self.touchBarItems = rawItems.compactMap { item in
|
||
guard let label = item["label"] as? String,
|
||
let action = item["action"] as? String else { return nil }
|
||
return ["label": label, "action": action]
|
||
}
|
||
// 强制重建 Touch Bar
|
||
self.touchBar = nil
|
||
if !self.touchBarItems.isEmpty {
|
||
self.touchBar = self.makeTouchBar()
|
||
}
|
||
result(nil)
|
||
|
||
// ---------- NSSharingService 共享 ----------
|
||
case "showShareSheet":
|
||
let args = call.arguments as? [String: Any] ?? [:]
|
||
var shareItems: [Any] = []
|
||
if let text = args["text"] as? String {
|
||
shareItems.append(text)
|
||
}
|
||
if let urlString = args["url"] as? String, let url = URL(string: urlString) {
|
||
shareItems.append(url)
|
||
}
|
||
if let imageBytes = args["imageBytes"] as? FlutterStandardTypedData,
|
||
let image = NSImage(data: imageBytes.data) {
|
||
shareItems.append(image)
|
||
}
|
||
guard !shareItems.isEmpty else {
|
||
result(FlutterError(code: "no_content",
|
||
message: "没有可分享的内容",
|
||
details: nil))
|
||
return
|
||
}
|
||
let picker = NSSharingServicePicker(items: shareItems)
|
||
if let view = self.contentView {
|
||
picker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
|
||
}
|
||
result(nil)
|
||
|
||
// ---------- 菜单栏语言切换 ----------
|
||
case "setMenuLanguage":
|
||
let args = call.arguments as? [String: Any] ?? [:]
|
||
let languageId = (args["languageId"] as? String) ?? "system"
|
||
self.setMenuLanguage(languageId: languageId)
|
||
result(nil)
|
||
|
||
default:
|
||
result(FlutterMethodNotImplemented)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - 主题同步实现
|
||
// ============================================================
|
||
|
||
/// 设置标题栏明暗模式(影响 NSWindow.appearance)
|
||
///
|
||
/// 自定义软件样式标题栏:titlebarAppearsTransparent = true,
|
||
/// 让 Flutter 侧的 DesktopWindowTitleBar 完全接管标题栏区域。
|
||
private func setDarkMode(isDark: Bool) {
|
||
let appearance: NSAppearance = isDark
|
||
? NSAppearance(named: .darkAqua)!
|
||
: NSAppearance(named: .aqua)!
|
||
NSApp.appearance = appearance
|
||
// 同步到所有窗口
|
||
for window in NSApplication.shared.windows {
|
||
window.appearance = appearance
|
||
// 标题栏透明,由 Flutter 侧自绘标题栏
|
||
window.titlebarAppearsTransparent = true
|
||
// 隐藏系统标题栏按钮(红黄绿),由 Flutter 侧自绘
|
||
window.standardWindowButton(.closeButton)?.isHidden = true
|
||
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||
window.standardWindowButton(.zoomButton)?.isHidden = true
|
||
// 标题栏高度为 0,让内容延伸到顶部
|
||
if let contentView = window.contentView {
|
||
contentView.superview?.wantsLayer = true
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 获取系统当前外观(light / dark)
|
||
private func getSystemAppearance() -> String {
|
||
let appearance = NSApp.effectiveAppearance.bestMatch(from: [
|
||
NSAppearance.Name.darkAqua,
|
||
NSAppearance.Name.aqua,
|
||
]) ?? NSAppearance.Name.aqua
|
||
return appearance == .darkAqua ? "dark" : "light"
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - 窗口管理实现
|
||
// ============================================================
|
||
|
||
/// 设置标题栏样式(auto / light / dark)
|
||
private func setTitleBarStyle(style: String) {
|
||
let appearance: NSAppearance?
|
||
switch style {
|
||
case "light":
|
||
appearance = NSAppearance(named: .aqua)
|
||
case "dark":
|
||
appearance = NSAppearance(named: .darkAqua)
|
||
default: // auto
|
||
appearance = nil
|
||
}
|
||
for window in NSApplication.shared.windows {
|
||
window.appearance = appearance
|
||
}
|
||
}
|
||
|
||
/// 设置窗口全屏
|
||
private func setFullscreen(fullscreen: Bool) {
|
||
let isCurrentlyFullscreen = self.styleMask.contains(.fullScreen)
|
||
if fullscreen && !isCurrentlyFullscreen {
|
||
self.toggleFullScreen(nil)
|
||
} else if !fullscreen && isCurrentlyFullscreen {
|
||
self.toggleFullScreen(nil)
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - 系统集成实现
|
||
// ============================================================
|
||
|
||
/// 触感反馈(macOS 通过 NSHapticFeedbackManager 实现)
|
||
private func performHapticFeedback(type: String) {
|
||
let feedbackLevel: NSHapticFeedbackManager.FeedbackPattern
|
||
switch type {
|
||
case "alignment":
|
||
feedbackLevel = .alignment
|
||
case "levelChange":
|
||
feedbackLevel = .levelChange
|
||
default:
|
||
feedbackLevel = .generic
|
||
}
|
||
NSHapticFeedbackManager.defaultPerformer.perform(
|
||
feedbackLevel,
|
||
performanceTime: .now
|
||
)
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - 菜单栏语言切换实现
|
||
// ============================================================
|
||
|
||
/// XIB 中所有 menuItem 的 objectId,按深度优先遍历顺序排列
|
||
/// 用于在 awakeFromNib 中为每个 menuItem 设置 identifier
|
||
/// 顺序:顶层菜单项 → 其子菜单项(跳过 separatorItem)
|
||
/// 与 MainMenu.xib 中 menuItem 的声明顺序一致
|
||
private static let menuItemIds: [String] = [
|
||
// App Menu(顶层 + 子菜单项)
|
||
"1Xt-HY-uBw", "5kV-Vb-QxS", "BOF-NM-1cW", "NMo-om-nkz",
|
||
"Olw-nP-bQN", "Vdr-fp-XzO", "Kd2-mp-pUS", "4sb-4s-VLi",
|
||
// Edit Menu(顶层 + 子菜单项)
|
||
"5QF-Oa-p0T", "dRJ-4n-Yzg", "6dh-zS-Vam", "uRl-iY-unG",
|
||
"x3v-GG-iWU", "gVA-U4-sdL", "WeT-3V-zwk", "pa3-QI-u2k",
|
||
"Ruw-6m-B2m",
|
||
// Edit > Find
|
||
"4EN-yA-p0u", "Xz5-n4-O0W", "YEy-JH-Tfz", "q09-fT-Sye",
|
||
"OwM-mh-QMV", "buJ-ug-pKt", "S0p-oC-mLd",
|
||
// Edit > Spelling and Grammar
|
||
"Dv1-io-Yv7", "HFo-cy-zxI", "hz2-CU-CR7", "rbD-Rh-wIN",
|
||
"mK6-2p-4JG", "78Y-hA-62v",
|
||
// Edit > Substitutions
|
||
"9ic-FL-obx", "z6F-FW-3nz", "9yt-4B-nSM", "hQb-2v-fYv",
|
||
"rgM-f4-ycn", "cwL-P1-jid", "tRr-pd-1PS", "HFQ-gK-NFA",
|
||
// Edit > Transformations
|
||
"2oI-Rn-ZJC", "vmV-6d-7jI", "d9M-CD-aMd", "UEZ-Bs-lqG",
|
||
// Edit > Speech
|
||
"xrE-MZ-jX0", "Ynk-f8-cLZ", "Oyz-dy-DGm",
|
||
// View Menu(顶层,子菜单项为空)
|
||
"H8h-7b-M4v",
|
||
// Window Menu(顶层 + 子菜单项)
|
||
"aUF-d1-5bR", "OY7-WF-poV", "R4o-n2-Eq4", "LE2-aR-0XJ",
|
||
// Help Menu(顶层,无子菜单项)
|
||
"EPT-qC-fAb",
|
||
]
|
||
|
||
/// 为 mainMenu 中每个 menuItem 设置 identifier(XIB objectId)
|
||
///
|
||
/// XIB 加载后,menuItem.identifier 默认为 nil,无法通过 objectId 查找。
|
||
/// 此方法按深度优先顺序遍历 mainMenu,将 menuItemIds 中的 objectId
|
||
/// 依次赋值给对应 menuItem.identifier,建立 objectId ↔ menuItem 映射。
|
||
/// 跳过 separatorItem(XIB 中 separator 无 objectId)。
|
||
private func assignMenuItemIdentifiers() {
|
||
var index = 0
|
||
func assign(_ menu: NSMenu) {
|
||
for item in menu.items {
|
||
if item.isSeparatorItem { continue }
|
||
if index < MainFlutterWindow.menuItemIds.count {
|
||
item.identifier = NSUserInterfaceItemIdentifier(
|
||
rawValue: MainFlutterWindow.menuItemIds[index]
|
||
)
|
||
index += 1
|
||
}
|
||
if let submenu = item.submenu {
|
||
assign(submenu)
|
||
}
|
||
}
|
||
}
|
||
guard let mainMenu = NSApp.mainMenu else {
|
||
NSLog("[assignMenuItemIdentifiers] NSApp.mainMenu 为 nil")
|
||
return
|
||
}
|
||
assign(mainMenu)
|
||
NSLog("[assignMenuItemIdentifiers] 已为 \(index) 个菜单项设置 identifier")
|
||
}
|
||
|
||
/// 切换 macOS 系统菜单栏语言
|
||
///
|
||
/// [languageId] 语言 ID:
|
||
/// - 'system' / 'auto' → 跟随系统语言(使用 preferredLocalizations)
|
||
/// - 'zh-CN' / 'zh-Hans' → 简体中文
|
||
/// - 'zh-TW' / 'zh-Hant' → 繁体中文
|
||
/// - 'en' → 英语
|
||
/// - 'ja' → 日语
|
||
/// - 'ko' → 韩语
|
||
///
|
||
/// 实现原理:
|
||
/// 1. 根据 languageId 解析对应的 lproj 目录名
|
||
/// 2. 加载对应语言的 MainMenu.strings 文件为 [String: String]
|
||
/// 3. 递归遍历 NSApp.mainMenu,根据 menuItem.identifier(XIB objectId)
|
||
/// 查找 .strings 中的 key("id.title")并更新标题
|
||
/// 4. 替换 "APP_NAME" 占位符为应用名(CFBundleName)
|
||
///
|
||
/// 注意:此方法仅更新菜单项标题,不重建菜单结构,不影响 action 连接。
|
||
private func setMenuLanguage(languageId: String) {
|
||
// 1. 解析 lproj 目录名
|
||
let lprojName: String
|
||
switch languageId {
|
||
case "system", "auto":
|
||
// preferredLocalizations(from:) 是 Bundle 类的静态方法,
|
||
// 返回系统首选语言在给定列表中的匹配顺序
|
||
let preferred = Bundle.preferredLocalizations(from: [
|
||
"en", "zh-Hans", "zh-Hant", "ja", "ko",
|
||
])
|
||
lprojName = preferred.first ?? "en"
|
||
case "zh-CN", "zh-Hans":
|
||
lprojName = "zh-Hans"
|
||
case "zh-TW", "zh-Hant":
|
||
lprojName = "zh-Hant"
|
||
case "en":
|
||
lprojName = "en"
|
||
case "ja":
|
||
lprojName = "ja"
|
||
case "ko":
|
||
lprojName = "ko"
|
||
default:
|
||
// 未知语言,回退到英语
|
||
lprojName = "en"
|
||
}
|
||
|
||
// 2. 加载对应语言的 MainMenu.strings
|
||
guard let stringsPath = Bundle.main.path(
|
||
forResource: "MainMenu",
|
||
ofType: "strings",
|
||
inDirectory: nil,
|
||
forLocalization: lprojName
|
||
) else {
|
||
NSLog("[setMenuLanguage] 未找到 \(lprojName).lproj/MainMenu.strings")
|
||
return
|
||
}
|
||
guard let strings = NSDictionary(contentsOfFile: stringsPath) as? [String: String] else {
|
||
NSLog("[setMenuLanguage] 加载 \(lprojName).lproj/MainMenu.strings 失败")
|
||
return
|
||
}
|
||
|
||
// 3. 递归遍历 mainMenu,更新标题
|
||
let appName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String) ?? "闲言"
|
||
var updatedCount = 0
|
||
func updateMenu(_ menu: NSMenu) {
|
||
for item in menu.items {
|
||
if item.isSeparatorItem { continue }
|
||
if let identifier = item.identifier?.rawValue {
|
||
let key = "\(identifier).title"
|
||
if let localized = strings[key] {
|
||
// 替换 APP_NAME 占位符为应用名
|
||
item.title = localized.replacingOccurrences(of: "APP_NAME", with: appName)
|
||
updatedCount += 1
|
||
}
|
||
}
|
||
if let submenu = item.submenu {
|
||
updateMenu(submenu)
|
||
}
|
||
}
|
||
}
|
||
guard let mainMenu = NSApp.mainMenu else {
|
||
NSLog("[setMenuLanguage] NSApp.mainMenu 为 nil")
|
||
return
|
||
}
|
||
updateMenu(mainMenu)
|
||
NSLog("[setMenuLanguage] 菜单栏语言已切换为 \(lprojName),更新 \(updatedCount) 个菜单项")
|
||
}
|
||
|
||
// ============================================================
|
||
// MARK: - Touch Bar 实现
|
||
// ============================================================
|
||
|
||
/// 创建 Touch Bar(系统在需要时自动调用,仅带 Touch Bar 的 MacBook 显示)
|
||
override func makeTouchBar() -> NSTouchBar? {
|
||
guard !touchBarItems.isEmpty else { return nil }
|
||
let touchBar = NSTouchBar()
|
||
touchBar.delegate = self
|
||
touchBar.customizationIdentifier = NSTouchBar.CustomizationIdentifier("apps.xy.xianyan.touchbar")
|
||
touchBar.defaultItemIdentifiers = touchBarItems.enumerated().map {
|
||
NSTouchBarItem.Identifier("apps.xy.xianyan.touchbar.item.\($0.offset)")
|
||
}
|
||
return touchBar
|
||
}
|
||
|
||
/// NSTouchBarDelegate:按索引创建 Touch Bar 按钮
|
||
func touchBar(_ touchBar: NSTouchBar, makeItemForIdentifier identifier: NSTouchBarItem.Identifier) -> NSTouchBarItem? {
|
||
guard let index = Int(identifier.rawValue.components(separatedBy: ".").last ?? ""),
|
||
index < touchBarItems.count else {
|
||
return nil
|
||
}
|
||
let item = touchBarItems[index]
|
||
let button = NSButton(title: item["label"] ?? "", target: self, action: #selector(touchBarButtonClicked(_:)))
|
||
button.identifier = NSUserInterfaceItemIdentifier(rawValue: item["action"] ?? "")
|
||
let touchBarItem = NSCustomTouchBarItem(identifier: identifier)
|
||
touchBarItem.view = button
|
||
return touchBarItem
|
||
}
|
||
|
||
/// Touch Bar 按钮点击回调,通过 channel.invokeMethod("touchBarAction", action) 通知 Dart
|
||
@objc private func touchBarButtonClicked(_ sender: NSButton) {
|
||
guard let action = sender.identifier?.rawValue else { return }
|
||
platformChannel?.invokeMethod("touchBarAction", arguments: action)
|
||
}
|
||
}
|