import AppKit
|
import AlignerCore
|
|
@MainActor
|
final class PermissionsWindowController: NSWindowController {
|
private let permissionManager: any SystemPermissionManaging
|
private let accessibilityStatus = NSTextField(labelWithString: "")
|
private let screenRecordingStatus = NSTextField(labelWithString: "")
|
var onPermissionStatusChanged: ((PermissionKind, PermissionStatus) -> Void)?
|
var onPermissionRequestStarted: ((PermissionKind) -> Void)?
|
private var lastKnownStatuses: [PermissionKind: PermissionStatus] = [:]
|
private var refreshTimer: Timer?
|
|
init(permissionManager: any SystemPermissionManaging) {
|
self.permissionManager = permissionManager
|
|
let window = NSWindow(
|
contentRect: NSRect(x: 0, y: 0, width: 520, height: 280),
|
styleMask: [.titled, .closable, .miniaturizable],
|
backing: .buffered,
|
defer: false
|
)
|
window.title = "Aligner Needs Permissions"
|
window.center()
|
|
super.init(window: window)
|
window.contentView = makeContentView()
|
refreshStatuses()
|
}
|
|
@available(*, unavailable)
|
required init?(coder: NSCoder) {
|
nil
|
}
|
|
override func showWindow(_ sender: Any?) {
|
super.showWindow(sender)
|
refreshStatuses()
|
startRefreshing()
|
}
|
|
override func close() {
|
stopRefreshing()
|
super.close()
|
}
|
|
private func makeContentView() -> NSView {
|
let title = NSTextField(labelWithString: "Aligner needs some permissions")
|
title.font = .systemFont(ofSize: 22, weight: .semibold)
|
|
let subtitle = NSTextField(wrappingLabelWithString: "These permissions are normal for a macOS window management app. They let Aligner focus windows and show real window previews.")
|
subtitle.textColor = .secondaryLabelColor
|
|
let stack = NSStackView()
|
stack.orientation = .vertical
|
stack.alignment = .leading
|
stack.spacing = 18
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
stack.addArrangedSubview(title)
|
stack.addArrangedSubview(subtitle)
|
stack.addArrangedSubview(permissionRow(
|
title: "Accessibility",
|
description: "Needed to focus or switch to the target window after you release the shortcut. If this stays red after you enable it, quit and reopen Aligner.",
|
statusLabel: accessibilityStatus,
|
action: #selector(requestAccessibilityPermission)
|
))
|
stack.addArrangedSubview(permissionRow(
|
title: "Screen Recording",
|
description: "Needed to show real screenshots and previews of open windows. If this stays red after you enable it, quit and reopen Aligner.",
|
statusLabel: screenRecordingStatus,
|
action: #selector(requestScreenRecordingPermission)
|
))
|
|
let root = NSView()
|
root.addSubview(stack)
|
NSLayoutConstraint.activate([
|
stack.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 28),
|
stack.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -28),
|
stack.topAnchor.constraint(equalTo: root.topAnchor, constant: 28),
|
stack.bottomAnchor.constraint(lessThanOrEqualTo: root.bottomAnchor, constant: -28)
|
])
|
return root
|
}
|
|
private func permissionRow(
|
title: String,
|
description: String,
|
statusLabel: NSTextField,
|
action: Selector
|
) -> NSView {
|
let titleLabel = NSTextField(labelWithString: title)
|
titleLabel.font = .systemFont(ofSize: 15, weight: .semibold)
|
|
let descriptionLabel = NSTextField(wrappingLabelWithString: description)
|
descriptionLabel.textColor = .secondaryLabelColor
|
|
let button = NSButton(title: "Request Permission...", target: self, action: action)
|
button.bezelStyle = .rounded
|
|
let row = NSStackView(views: [titleLabel, descriptionLabel, statusLabel, button])
|
row.orientation = .vertical
|
row.alignment = .leading
|
row.spacing = 6
|
return row
|
}
|
|
private func startRefreshing() {
|
stopRefreshing()
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
|
Task { @MainActor in
|
self?.refreshStatuses()
|
}
|
}
|
}
|
|
private func stopRefreshing() {
|
refreshTimer?.invalidate()
|
refreshTimer = nil
|
}
|
|
private func refreshStatuses() {
|
refresh(.accessibility, label: accessibilityStatus)
|
refresh(.screenRecording, label: screenRecordingStatus)
|
}
|
|
private func refresh(_ kind: PermissionKind, label: NSTextField) {
|
let status = permissionManager.status(for: kind)
|
update(label, with: status)
|
|
guard lastKnownStatuses[kind] != status else { return }
|
lastKnownStatuses[kind] = status
|
onPermissionStatusChanged?(kind, status)
|
}
|
|
private func update(_ label: NSTextField, with status: PermissionStatus) {
|
switch status {
|
case .granted:
|
label.stringValue = "Granted"
|
label.textColor = .systemGreen
|
case .notGranted:
|
label.stringValue = "Not granted"
|
label.textColor = .systemRed
|
}
|
}
|
|
@objc private func requestAccessibilityPermission() {
|
requestPermission(
|
.accessibility,
|
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
)
|
}
|
|
@objc private func requestScreenRecordingPermission() {
|
requestPermission(
|
.screenRecording,
|
settingsURL: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
)
|
}
|
|
private func requestPermission(_ kind: PermissionKind, settingsURL: String) {
|
onPermissionRequestStarted?(kind)
|
_ = permissionManager.request(kind)
|
refreshStatuses()
|
|
guard permissionManager.status(for: kind) != .granted else { return }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
|
self?.openSettings(settingsURL)
|
self?.refreshStatuses()
|
}
|
}
|
|
private func openSettings(_ urlString: String) {
|
guard let url = URL(string: urlString) else { return }
|
NSWorkspace.shared.open(url)
|
}
|
}
|