From 8dda2dae6638dce58551343c2fe3d12aa031bb7f Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 1 May 2026 02:24:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E5=AE=9A=E4=B9=89=E6=9D=83?= =?UTF-8?q?=E9=99=90=E5=BC=95=E5=AF=BC=E7=AA=97=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PermissionGuideWindow 替代 NSAlert 权限引导 - 辅助功能用 AXIsProcessTrusted() 实时检测 - 输入监控因 macOS 15 API 限制标记为手动确认 - 每项权限有「打开设置」按钮跳转系统设置页 - 轮询刷新,辅助功能就绪后可关闭窗口启动 --- Sources/App/AppDelegate.swift | 82 ++------ Sources/UI/PermissionGuideWindow.swift | 250 +++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 70 deletions(-) create mode 100644 Sources/UI/PermissionGuideWindow.swift diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 7b9aaad..35e9cb9 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -51,88 +51,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSSpeechSynthesizerDel // MARK: - Permissions - private func beginPermissionFlow() { - let hasAccessibility = CGPreflightListenEventAccess() - let hasInputMonitoring = canCreateHIDEventTapSimple() + private var permissionGuideWindow: PermissionGuideWindow? - // 两个权限都 OK → 直接启动 - if hasAccessibility && hasInputMonitoring { + private func beginPermissionFlow() { + // 辅助功能权限已就绪 → 直接启动(输入监控可选,KeyMonitor 有回退) + if PermissionGuideWindow.checkAccessibility() { startKeyMonitor() return } - // 打开系统设置隐私主页 - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security")!) - - // 列出缺失的权限 - var missing: [String] = [] - if !hasAccessibility { missing.append("辅助功能") } - if !hasInputMonitoring { missing.append("输入监控") } - - let alert = NSAlert() - alert.messageText = "需要系统权限" - alert.informativeText = "VoiceInput 需要以下权限才能正常工作:\n\n• \(missing.joined(separator: "\n• "))\n\n请在左侧列表中找到对应项目,点击 + 添加 VoiceInput,然后打开开关。" - alert.alertStyle = .informational - alert.addButton(withTitle: "已授权,继续") - alert.addButton(withTitle: "退出") - guard alert.runModal() == .alertFirstButtonReturn else { NSApp.terminate(nil); return } - - // 轮询检测 - Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in - guard let self = self else { return } - let ok1 = CGPreflightListenEventAccess() - let ok2 = self.canCreateHIDEventTapSimple() - if ok1 && ok2 { - timer.invalidate() - DispatchQueue.main.async { - print("[Permission] ✅ All permissions granted") - self.startKeyMonitor() - self.showReadyAlert() - } - } + // 弹出自定义权限引导窗口(内含轮询) + let window = PermissionGuideWindow { + print("[Permission] ✅ All permissions granted") + self.startKeyMonitor() } - } - - /// 简单检测输入监控(仅用于引导,KeyMonitor 内部有 HID→Session 回退) - private func canCreateHIDEventTapSimple() -> Bool { - let mask = (1 << CGEventType.flagsChanged.rawValue) - guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, - options: .defaultTap, eventsOfInterest: CGEventMask(mask), - callback: { _, _, _, _ in return nil }, userInfo: nil) else { return false } - CFMachPortInvalidate(tap) - return true - } - - private func showReadyAlert() { - let alert = NSAlert() - alert.messageText = "权限已就绪" - alert.informativeText = "Voice Input 正在运行。首次使用时还需授权麦克风和语音识别。" - alert.alertStyle = .informational - alert.runModal() + permissionGuideWindow = window + window.makeKeyAndOrderFront(nil) } private func showMicrophonePermissionAlert() { - let alert = NSAlert() - alert.messageText = "需要麦克风权限" - alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 麦克风,开启 VoiceInput 的权限。" - alert.alertStyle = .warning - alert.addButton(withTitle: "打开系统设置") - alert.addButton(withTitle: "取消") - if alert.runModal() == .alertFirstButtonReturn { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) - } + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!) } private func showSpeechPermissionAlert() { - let alert = NSAlert() - alert.messageText = "需要语音识别权限" - alert.informativeText = "请前往 系统设置 > 隐私与安全性 > 语音识别,开启 VoiceInput 的权限。" - alert.alertStyle = .warning - alert.addButton(withTitle: "打开系统设置") - alert.addButton(withTitle: "取消") - if alert.runModal() == .alertFirstButtonReturn { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")!) - } + NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_SpeechRecognition")!) } // MARK: - Key Monitor diff --git a/Sources/UI/PermissionGuideWindow.swift b/Sources/UI/PermissionGuideWindow.swift new file mode 100644 index 0000000..4586ad1 --- /dev/null +++ b/Sources/UI/PermissionGuideWindow.swift @@ -0,0 +1,250 @@ +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) + } + } +}