feat: 自定义权限引导窗口

- 新增 PermissionGuideWindow 替代 NSAlert 权限引导
- 辅助功能用 AXIsProcessTrusted() 实时检测
- 输入监控因 macOS 15 API 限制标记为手动确认
- 每项权限有「打开设置」按钮跳转系统设置页
- 轮询刷新,辅助功能就绪后可关闭窗口启动
This commit is contained in:
JOJO 2026-05-01 02:24:54 +08:00
parent b8dfd4d85b
commit 8dda2dae66
2 changed files with 262 additions and 70 deletions

View File

@ -51,89 +51,31 @@ 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 {
//
let window = PermissionGuideWindow {
print("[Permission] ✅ All permissions granted")
self.startKeyMonitor()
self.showReadyAlert()
}
}
}
}
/// KeyMonitor HIDSession 退
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")!)
}
}
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")!)
}
}
// MARK: - Key Monitor

View File

@ -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)
}
}
}