- 新增 PermissionGuideWindow 替代 NSAlert 权限引导 - 辅助功能用 AXIsProcessTrusted() 实时检测 - 输入监控因 macOS 15 API 限制标记为手动确认 - 每项权限有「打开设置」按钮跳转系统设置页 - 轮询刷新,辅助功能就绪后可关闭窗口启动
251 lines
9.1 KiB
Swift
251 lines
9.1 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|