feat: turn Sst into a regular Mac app with settings, menus and menu bar item
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
import AVFoundation
|
||||
import Combine
|
||||
|
||||
/// Reads the three macOS permissions and keeps them current: on every app activation and while polling is on.
|
||||
@MainActor
|
||||
final class PermissionMonitor: ObservableObject {
|
||||
enum Kind: CaseIterable, Identifiable {
|
||||
case microphone, accessibility, inputMonitoring
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .microphone: Strings.microphone
|
||||
case .accessibility: Strings.accessibility
|
||||
case .inputMonitoring: Strings.inputMonitoring
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate var pane: String {
|
||||
switch self {
|
||||
case .microphone: "Privacy_Microphone"
|
||||
case .accessibility: "Privacy_Accessibility"
|
||||
case .inputMonitoring: "Privacy_ListenEvent"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var microphone = false
|
||||
@Published private(set) var accessibility = false
|
||||
@Published private(set) var inputMonitoring = false
|
||||
|
||||
private var timer: Timer?
|
||||
private var activation: AnyCancellable?
|
||||
|
||||
init() {
|
||||
refresh()
|
||||
activation = NotificationCenter.default
|
||||
.publisher(for: NSApplication.didBecomeActiveNotification)
|
||||
.sink { [weak self] _ in MainActor.assumeIsolated { self?.refresh() } }
|
||||
}
|
||||
|
||||
var allGranted: Bool { microphone && accessibility && inputMonitoring }
|
||||
|
||||
func isGranted(_ kind: Kind) -> Bool {
|
||||
switch kind {
|
||||
case .microphone: microphone
|
||||
case .accessibility: accessibility
|
||||
case .inputMonitoring: inputMonitoring
|
||||
}
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
microphone = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
accessibility = AXIsProcessTrusted()
|
||||
inputMonitoring = CGPreflightListenEventAccess()
|
||||
}
|
||||
|
||||
/// Asks macOS for every permission that is still missing (each shows its own system prompt).
|
||||
func requestMissing() async {
|
||||
if !microphone { _ = await AVCaptureDevice.requestAccess(for: .audio) }
|
||||
if !inputMonitoring { _ = CGRequestListenEventAccess() }
|
||||
if !accessibility { _ = AXIsProcessTrustedWithOptions(["AXTrustedCheckOptionPrompt": true] as CFDictionary) }
|
||||
refresh()
|
||||
}
|
||||
|
||||
func openSettings(for kind: Kind) {
|
||||
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(kind.pane)") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls while a view that shows the lamps is visible, so granting a permission shows up without a click.
|
||||
func startPolling(every interval: TimeInterval = 2) {
|
||||
guard timer == nil else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
MainActor.assumeIsolated { self?.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
func stopPolling() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user