Files
OSGKeyboard/OSGKeyboardExt/Services/PermissionManager.swift
T
Rocky 7f059dbd45 feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start,
tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group
IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
2026-06-19 18:05:50 +08:00

81 lines
3.0 KiB
Swift

// PermissionManager.swift
// OSGKeyboard · Keyboard Extension
//
// Extracted from KeyboardViewController so the view controller doesn't
// need to know about AVAudioApplication vs AVAudioSession branching
// or SFSpeechRecognizer.requestAuthorization callback bridging.
//
// Contract:
// • `requestMicPermission()` returns true if the user has authorised
// or *just* authorised; false otherwise. Idempotent within a
// process — the second call will not prompt again if the user has
// already answered.
// • `requestSpeechPermission()` mirrors the same shape but for
// SFSpeechRecognizer.
import Foundation
import AVFoundation
import Speech
@MainActor
public final class PermissionManager: @unchecked Sendable {
public init() {}
private var didRequestMicOnce: Bool = false
/// Request microphone access. Returns true if granted (already or
/// after this call). Uses the iOS 17+ `AVAudioApplication` API.
public func requestMicPermission() async -> Bool {
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
if !didRequestMicOnce {
didRequestMicOnce = true
return await AVAudioApplication.requestRecordPermission()
}
return false
@unknown default: return false
}
}
/// Request Speech Recognition permission. Returns true if granted
/// (already or after this call). The `SFSpeechRecognizer` plist
/// key + this call are still required even on iOS 26 — the
/// `SpeechAnalyzer` API does not expose an explicit request
/// method of its own and the framework checks the same TCC
/// entry on first use.
public func requestSpeechPermission() async -> Bool {
await Self.requestSpeechPermissionNonisolated()
}
// MARK: - Nonisolated permission bridge
//
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
// to run on main queue. Building the callback inline inside a
// `@MainActor` method can trigger runtime actor/isolation assertions.
// Keep the continuation + callback creation in nonisolated helpers.
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization(
makeSpeechAuthHandler(continuation: cont)
)
}
}
private nonisolated static func makeSpeechAuthHandler(
continuation: CheckedContinuation<Bool, Never>
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
return { status in
switch status {
case .authorized:
continuation.resume(returning: true)
case .denied, .restricted, .notDetermined:
continuation.resume(returning: false)
@unknown default:
continuation.resume(returning: false)
}
}
}
}