VoiceInput/Sources/UI/PermissionGuideWindow.swift
JOJO 8dda2dae66 feat: 自定义权限引导窗口
- 新增 PermissionGuideWindow 替代 NSAlert 权限引导
- 辅助功能用 AXIsProcessTrusted() 实时检测
- 输入监控因 macOS 15 API 限制标记为手动确认
- 每项权限有「打开设置」按钮跳转系统设置页
- 轮询刷新,辅助功能就绪后可关闭窗口启动
2026-05-01 02:24:54 +08:00

251 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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