feat: 自定义权限引导窗口
- 新增 PermissionGuideWindow 替代 NSAlert 权限引导 - 辅助功能用 AXIsProcessTrusted() 实时检测 - 输入监控因 macOS 15 API 限制标记为手动确认 - 每项权限有「打开设置」按钮跳转系统设置页 - 轮询刷新,辅助功能就绪后可关闭窗口启动
This commit is contained in:
parent
b8dfd4d85b
commit
8dda2dae66
@ -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 内部有 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")!)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
250
Sources/UI/PermissionGuideWindow.swift
Normal file
250
Sources/UI/PermissionGuideWindow.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user