/// ============================================================ /// 闲言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) } }