Phase 0: menu-bar scaffold, permissions, backend health check
Native SwiftUI menu-bar app (LSUIElement, macOS 13+), generated from project.yml via XcodeGen. Includes: - PermissionsManager (Microphone / Screen Recording / Accessibility) + UI - SparkControlHealth: GET /api/status over self-signed TLS (InsecureTrustDelegate) - AppSettings persistence (host, TLS-skip, output folder, adapter toggles) - Menu-bar panel + Settings, app sandbox & hardened runtime off (LAN tool)
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import AVFoundation
|
||||
import CoreGraphics
|
||||
import ApplicationServices
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
enum PermissionState {
|
||||
case granted
|
||||
case denied
|
||||
case notDetermined
|
||||
}
|
||||
|
||||
/// Tracks and requests the three TCC permissions the app needs.
|
||||
///
|
||||
/// - Microphone: AVFoundation authorization (has a real "not determined" state).
|
||||
/// - Screen Recording: CoreGraphics preflight/request (binary granted/denied).
|
||||
/// - Accessibility: AXIsProcessTrusted (binary granted/denied).
|
||||
@MainActor
|
||||
final class PermissionsManager: ObservableObject {
|
||||
|
||||
@Published private(set) var microphone: PermissionState = .notDetermined
|
||||
@Published private(set) var screenRecording: PermissionState = .notDetermined
|
||||
@Published private(set) var accessibility: PermissionState = .notDetermined
|
||||
|
||||
init() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
microphone = Self.microphoneState()
|
||||
screenRecording = CGPreflightScreenCaptureAccess() ? .granted : .denied
|
||||
accessibility = AXIsProcessTrusted() ? .granted : .denied
|
||||
}
|
||||
|
||||
// MARK: - Requests
|
||||
|
||||
func requestMicrophone() {
|
||||
AVCaptureDevice.requestAccess(for: .audio) { _ in
|
||||
Task { @MainActor in self.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers the system Screen Recording prompt on first call. The user must
|
||||
/// still toggle the app on in System Settings; `refresh()` reflects it after.
|
||||
func requestScreenRecording() {
|
||||
_ = CGRequestScreenCaptureAccess()
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Shows the Accessibility trust prompt (deep-links to the right pane).
|
||||
func requestAccessibility() {
|
||||
// Literal is the value of `kAXTrustedCheckOptionPrompt`; used directly to
|
||||
// stay robust across SDK import shapes of that constant.
|
||||
let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary
|
||||
_ = AXIsProcessTrustedWithOptions(options)
|
||||
refresh()
|
||||
}
|
||||
|
||||
func openSettings(_ pane: SettingsPane) {
|
||||
guard let url = URL(string: pane.urlString) else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func microphoneState() -> PermissionState {
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized: return .granted
|
||||
case .denied, .restricted: return .denied
|
||||
case .notDetermined: return .notDetermined
|
||||
@unknown default: return .notDetermined
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsPane {
|
||||
case microphone
|
||||
case screenRecording
|
||||
case accessibility
|
||||
|
||||
var urlString: String {
|
||||
let root = "x-apple.systempreferences:com.apple.preference.security?"
|
||||
switch self {
|
||||
case .microphone: return root + "Privacy_Microphone"
|
||||
case .screenRecording: return root + "Privacy_ScreenCapture"
|
||||
case .accessibility: return root + "Privacy_Accessibility"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user