Files
xianyan/macos/Runner/MainFlutterWindow.swift
Developer 93d580fa1e feat(macos): 实现 macOS 系统菜单栏多语言支持与闪退修复
本次提交完成 macOS 端多项关键优化与修复:
1. 修复启动期竞态闪退问题,通过前移窗口属性初始化、串行化特效调用、跳过首次同步实现稳定启动
2. 实现系统菜单栏多语言本地化,支持中英日韩繁五种语言,软件内切换语言可同步更新菜单栏
3. 移除视图菜单中重复的全屏按钮,统一窗口标题栏逻辑
4. 新增 macOS App Store 打包配置与本地化资源
2026-06-24 08:35:08 +08:00

570 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 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: selfstart() 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 identifierXIB 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 identifierXIB objectId
///
/// XIB menuItem.identifier nil objectId
/// mainMenu menuItemIds objectId
/// menuItem.identifier objectId menuItem
/// separatorItemXIB 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.identifierXIB 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)
}
}