import Cocoa // MARK: - 权限引导窗口 /// 启动时权限不足时弹出,显示各权限状态并提供跳转按钮 final class PermissionGuideWindow: NSPanel { // MARK: - Permission Detection static func checkAccessibility() -> Bool { return AXIsProcessTrusted() } // MARK: - Views private let titleLabel = NSTextField(labelWithString: "VoiceInput 需要系统权限") private let subtitleLabel = NSTextField(labelWithString: "请前往系统设置逐项开启以下权限") private let stackView = NSStackView() private var accessibilityRow: PermissionRow! private var inputMonitoringRow: PermissionRow! // MARK: - Timer private var pollTimer: Timer? private var onAllReady: (() -> Void)? // MARK: - Init init(onAllReady: @escaping () -> Void) { self.onAllReady = onAllReady super.init( contentRect: NSRect(x: 0, y: 0, width: 420, height: 200), styleMask: [.titled, .closable, .nonactivatingPanel], backing: .buffered, defer: false ) setupUI() refreshAccessibility() pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in self?.refreshAccessibility() } } // MARK: - UI Setup private func setupUI() { isFloatingPanel = true level = .floating isReleasedWhenClosed = false title = "权限设置" center() titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .systemFont(ofSize: 16, weight: .bold) titleLabel.alignment = .center titleLabel.textColor = .labelColor subtitleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.font = .systemFont(ofSize: 12, weight: .regular) subtitleLabel.alignment = .center subtitleLabel.textColor = .secondaryLabelColor // 辅助功能行 accessibilityRow = PermissionRow( name: "辅助功能", settingURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", hint: nil ) // 输入监控行 — 不可靠检测,始终手动确认 inputMonitoringRow = PermissionRow( name: "输入监控", settingURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", hint: "授权后需重启应用生效" ) inputMonitoringRow.setManual(text: "请手动开启") stackView.translatesAutoresizingMaskIntoConstraints = false stackView.orientation = .vertical stackView.alignment = .leading stackView.spacing = 8 stackView.edgeInsets = NSEdgeInsets(top: 8, left: 24, bottom: 8, right: 24) stackView.addArrangedSubview(accessibilityRow) stackView.addArrangedSubview(inputMonitoringRow) // 两行等宽,保证按钮对齐 NSLayoutConstraint.activate([ accessibilityRow.widthAnchor.constraint(equalTo: stackView.widthAnchor), inputMonitoringRow.widthAnchor.constraint(equalTo: stackView.widthAnchor) ]) let closeButton = NSButton(title: "关闭应用", target: self, action: #selector(closeApp)) closeButton.bezelStyle = .rounded closeButton.translatesAutoresizingMaskIntoConstraints = false guard let contentView = contentView else { return } contentView.subviews.forEach { $0.removeFromSuperview() } contentView.addSubview(titleLabel) contentView.addSubview(subtitleLabel) contentView.addSubview(stackView) contentView.addSubview(closeButton) NSLayoutConstraint.activate([ titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), titleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4), subtitleLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), stackView.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 12), stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), closeButton.topAnchor.constraint(equalTo: stackView.bottomAnchor, constant: 12), closeButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), closeButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) ]) } // MARK: - Refresh private func refreshAccessibility() { let granted = Self.checkAccessibility() accessibilityRow.setGranted(granted) // 辅助功能就绪时关闭也启用(让用户可以关闭并继续) // 不自动关闭,用户手动关闭 } // MARK: - Actions @objc private func closeApp() { pollTimer?.invalidate() pollTimer = nil close() // 关闭窗口后启动 KeyMonitor(不管权限是否全,用户已确认) onAllReady?() } override func close() { pollTimer?.invalidate() pollTimer = nil super.close() } } // MARK: - 单行视图 private final class PermissionRow: NSView { private let nameLabel = NSTextField(labelWithString: "") private let statusLabel = NSTextField(labelWithString: "") private let settingButton = NSButton(title: "打开设置", target: nil, action: nil) private let hintLabel = NSTextField(labelWithString: "") private let settingURL: String init(name: String, settingURL: String, hint: String?) { self.settingURL = settingURL super.init(frame: .zero) setupUI(name: name, hint: hint) } required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") } private func setupUI(name: String, hint: String?) { translatesAutoresizingMaskIntoConstraints = false nameLabel.stringValue = name nameLabel.font = .systemFont(ofSize: 14, weight: .medium) nameLabel.textColor = .labelColor nameLabel.translatesAutoresizingMaskIntoConstraints = false statusLabel.font = .systemFont(ofSize: 13, weight: .regular) statusLabel.translatesAutoresizingMaskIntoConstraints = false statusLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) settingButton.bezelStyle = .rounded settingButton.font = .systemFont(ofSize: 12) settingButton.controlSize = .small settingButton.target = self settingButton.action = #selector(openSettings) settingButton.translatesAutoresizingMaskIntoConstraints = false addSubview(nameLabel) addSubview(statusLabel) addSubview(settingButton) var constraints: [NSLayoutConstraint] = [ // 名称固定左对齐 nameLabel.leadingAnchor.constraint(equalTo: leadingAnchor), nameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), nameLabel.widthAnchor.constraint(equalToConstant: 70), // 按钮固定右对齐 — 两行的按钮水平位置一致(都距 trailing 为 0) settingButton.trailingAnchor.constraint(equalTo: trailingAnchor), settingButton.centerYAnchor.constraint(equalTo: centerYAnchor), settingButton.widthAnchor.constraint(equalToConstant: 80), // 状态在名称和按钮之间 statusLabel.leadingAnchor.constraint(equalTo: nameLabel.trailingAnchor, constant: 8), statusLabel.centerYAnchor.constraint(equalTo: centerYAnchor), statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: settingButton.leadingAnchor, constant: -8), // 统一行高 32 heightAnchor.constraint(equalToConstant: 32) ] if let hint = hint { hintLabel.stringValue = hint hintLabel.font = .systemFont(ofSize: 10, weight: .regular) hintLabel.textColor = .secondaryLabelColor hintLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(hintLabel) constraints.append(contentsOf: [ hintLabel.leadingAnchor.constraint(equalTo: statusLabel.leadingAnchor), hintLabel.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 0), hintLabel.trailingAnchor.constraint(equalTo: settingButton.leadingAnchor, constant: -8) ]) } NSLayoutConstraint.activate(constraints) } func setGranted(_ granted: Bool) { if granted { statusLabel.stringValue = "✅ 已就绪" statusLabel.textColor = .systemGreen } else { statusLabel.stringValue = "⛔ 无权限" statusLabel.textColor = .systemRed } } func setManual(text: String) { statusLabel.stringValue = text statusLabel.textColor = .systemOrange } @objc private func openSettings() { if let url = URL(string: settingURL) { NSWorkspace.shared.open(url) } } }