1. 初识菜单栏应用开发

让我们从一个天气助手应用的需求说起。用户需要常驻菜单栏的温度显示,单击弹出详细天气预报窗口,还能访问本地文件记录天气日志。这正是macOS应用开发的典型场景:需要同时处理系统资源访问、界面状态管理和系统权限控制。

1.1 菜单栏基础实现

import SwiftUI

@main
struct WeatherAssistantApp: App {
    // 状态管理菜单栏项(macOS 12+)
    @StateObject private var statusItem = StatusItemController()
    
    var body: some Scene {
        WindowGroup {
            EmptyView() // 主窗口隐藏
        }
    }
}

class StatusItemController: NSObject, ObservableObject {
    private var statusItem: NSStatusItem?
    
    override init() {
        super.init()
        setupStatusItem()
    }
    
    private func setupStatusItem() {
        statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
        
        let menu = NSMenu()
        menu.addItem(NSMenuItem(title: "实时天气", action: #selector(showWeather), keyEquivalent: ""))
        menu.addItem(NSMenuItem.separator())
        menu.addItem(NSMenuItem(title: "退出", action: #selector(quitApp), keyEquivalent: "q"))
        
        statusItem?.menu = menu
        updateTemperatureDisplay("28℃") // 初始化温度显示
    }
    
    // 更新温度显示(支持SF Symbols)
    func updateTemperatureDisplay(_ temp: String) {
        let button = statusItem?.button
        button?.image = NSImage(
            systemSymbolName: "thermometer",
            accessibilityDescription: nil
        )
        button?.title = temp
        button?.imagePosition = .imageLeft // 图标在文字左侧
    }
    
    @objc private func showWeather() {
        // 窗口显示逻辑后续讲解
    }
    
    @objc private func quitApp() {
        NSApplication.shared.terminate(nil)
    }
}

这个基础实现展示了:

  • NSStatusBar创建系统菜单栏项
  • NSMenu构建带分隔符的菜单结构
  • SF Symbols图标系统的应用
  • 图像文字混合排版技巧

2. 多窗口管理系统

2.1 主窗口与浮动面板

当用户点击菜单项时,我们需要显示包含详细天气的独立窗口:

// 在WeatherAssistantApp中追加场景定义
var body: some Scene {
    WindowGroup {
        EmptyView()
    }
    
    // 详细天气窗口
    Window("天气详情", id: "weatherDetail") {
        WeatherDetailView()
            .frame(minWidth: 400, idealWidth: 500, maxWidth: 600,
                   minHeight: 300, idealHeight: 400, maxHeight: 500)
    }
    .windowStyle(.hiddenTitleBar) // 隐藏标题栏
    .defaultSize(width: 500, height: 400)
    
    // 设置浮动面板
    Settings {
        SettingsView()
    }
}

2.2 窗口生命周期控制

在StatusItemController中添加窗口管理:

@objc private func showWeather() {
    // 激活应用防止窗口出现在后台
    NSApp.activate(ignoringOtherApps: true)
    
    // 获取场景实例
    if let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "weatherDetail" }) {
        window.makeKeyAndOrderFront(nil) // 激活已有窗口
    } else {
        // 使用NSWindowController创建新窗口
        let contentView = WeatherDetailView()
        let window = NSWindow(
            contentRect: NSRect(origin: .zero, size: CGSize(width: 500, height: 400)),
            styleMask: [.titled, .closable, .miniaturizable],
            backing: .buffered,
            defer: false
        )
        window.contentView = NSHostingView(rootView: contentView)
        window.makeKeyAndOrderFront(nil)
    }
}

3. 沙盒权限攻防战

3.1 基础权限配置

在Xcode的Signing & Capabilities中添加沙盒配置,勾选所需权限:

必需配置项:

  • User Selected File: Read/Write
  • App Data: Read/Write
  • Outgoing Connections (Client)

3.2 文件访问实战

struct LogView: View {
    @State private var logContent = ""
    
    var body: some View {
        VStack {
            TextEditor(text: $logContent)
                .font(.system(.body, design: .monospaced))
            Button("选择日志文件") {
                selectLogFile()
            }
        }
    }
    
    private func selectLogFile() {
        let panel = NSOpenPanel()
        panel.allowedContentTypes = [.plainText]
        panel.begin { response in
            guard response == .OK, let url = panel.url else { return }
            loadFileContents(at: url)
        }
    }
    
    private func loadFileContents(at url: URL) {
        // 先获取安全访问权限
        url.startAccessingSecurityScopedResource()
        defer { url.stopAccessingSecurityScopedResource() }
        
        do {
            logContent = try String(contentsOf: url)
        } catch {
            print("文件读取失败: \(error.localizedDescription)")
        }
    }
}

3.3 网络请求权限处理

在Info.plist中添加:

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

4. 技术深潜与避坑指南

4.1 场景匹配建议

菜单栏应用最适合:

  • 系统监控工具(CPU/内存显示)
  • 快捷操作中心(剪贴板管理)
  • 即时通讯状态提示
  • 媒体播放控制中心

4.2 SwiftUI的优势与局限

优势项:

  • 声明式语法加速界面开发
  • 实时预览提升开发效率
  • 跨平台代码复用潜力

需要注意的:

  • 部分NSView组件尚未适配
  • 菜单栏深度定制受限
  • 窗口生命周期需结合AppKit

4.3 安全存储实践

// 使用UserDefaults的安全存储
func saveAPIToken(_ token: String) {
    let query: [String: Any] = [
        kSecClass as String: kSecClassGenericPassword,
        kSecAttrAccount as String: "WeatherAPI",
        kSecValueData as String: token.data(using: .utf8)!
    ]
    SecItemDelete(query as CFDictionary)
    SecItemAdd(query as CFDictionary, nil)
}

5. 构建完整工作流

5.1 调试技巧

在终端运行:

# 查看沙盒容器路径
xcrun simctl get_app_container booted com.yourcompany.WeatherAssistant data

5.2 打包注意事项

必须配置:

  • 开启Hardened Runtime
  • 添加Notarization所需权限
  • 正确配置App Transport Security

6. 应用场景与总结

适合需要常驻后台但避免打扰用户的工具类应用。通过沙盒机制保障系统安全,但要注意权限请求的合理范围。窗口管理建议采用系统推荐模式,避免创造不符合macOS设计规范的操作方式。