feat: harden Flow cold-start/force-quit and polish macOS dictation UX
Fix cold-start overlay recursion that overflowed the main-thread stack when recording began while the ready overlay was still up; also remove temporary on-screen Flow DEBUG panels after the orange-mic investigation, and land the macOS overlay/catalog/layout polish plus related Flow recovery hardening.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
// ASRChunkTranscribing.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Minimal ASR surface for pipelined utterance chunking. Keeps
|
||||
// `ChunkedUtterancePipeline` independent of iOS-only `SpeechAnalyzer`.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum ASRChunkResult: Sendable, Equatable {
|
||||
case success(String)
|
||||
case failure(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
/// One-shot chunk transcription used by `ChunkedUtterancePipeline`.
|
||||
public protocol ASRChunkTranscribing: Sendable {
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
func cancel()
|
||||
func resetForNewUtterance()
|
||||
}
|
||||
|
||||
extension ASRChunkTranscribing {
|
||||
public func cancel() {}
|
||||
public func resetForNewUtterance() {}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
|
||||
|
||||
// MARK: - Protocol
|
||||
|
||||
public protocol ASRService: Sendable {
|
||||
public protocol ASRService: ASRChunkTranscribing, Sendable {
|
||||
/// Start a transcription session. The returned stream emits `.partial`
|
||||
/// updates and exactly one `.final` (or `.error`) before finishing.
|
||||
/// `SpeechAnalyzer` is always fully on-device, so there is no
|
||||
@@ -54,12 +54,6 @@ public protocol ASRService: Sendable {
|
||||
func transcribeChunk(samples: [Float], locale: Locale) async -> ASRChunkResult
|
||||
}
|
||||
|
||||
public enum ASRChunkResult: Sendable, Equatable {
|
||||
case success(String)
|
||||
case failure(String)
|
||||
case cancelled
|
||||
}
|
||||
|
||||
extension ASRService {
|
||||
public func resetForNewUtterance() {}
|
||||
|
||||
|
||||
@@ -53,6 +53,10 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
public var baseURL: String { configuration.baseURL }
|
||||
public var apiKey: String { configuration.apiKey }
|
||||
public var model: String { configuration.model }
|
||||
public var asrProviderId: String { configuration.asrProviderId }
|
||||
public var asrBaseURL: String { configuration.resolvedASRBaseURL }
|
||||
public var asrApiKey: String { configuration.asrApiKey }
|
||||
public var asrModel: String { configuration.resolvedASRModel }
|
||||
public var modeId: String { configuration.modeId }
|
||||
public var localeId: String { configuration.localeId }
|
||||
public var engineMode: String { configuration.engineMode }
|
||||
@@ -91,6 +95,12 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
config.baseURL = openAI.defaultBaseURL
|
||||
config.model = openAI.defaultModel
|
||||
}
|
||||
if mode == "cloud", config.asrProviderId == "deepseek" {
|
||||
let openAI = LLMProvider.provider(id: "openai")
|
||||
config.asrProviderId = openAI.id
|
||||
config.asrBaseURL = openAI.defaultBaseURL
|
||||
config.asrModel = CloudASRModelCatalog.defaultModel(for: openAI.id)
|
||||
}
|
||||
}
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
@@ -67,13 +67,13 @@ private actor ChunkWorkQueue {
|
||||
}
|
||||
|
||||
public actor ChunkedUtterancePipeline {
|
||||
private let asr: ASRService
|
||||
private let asr: any ASRChunkTranscribing
|
||||
private let locale: Locale
|
||||
private let config: FlowUtteranceChunkConfig
|
||||
private var cancelled = false
|
||||
|
||||
public init(
|
||||
asr: ASRService,
|
||||
asr: any ASRChunkTranscribing,
|
||||
locale: Locale,
|
||||
config: FlowUtteranceChunkConfig = .flowDefault
|
||||
) {
|
||||
|
||||
@@ -17,31 +17,35 @@ public protocol CloudASRTranscribing: Sendable {
|
||||
|
||||
public enum CloudASRClientFactory {
|
||||
public static func make(store: any ConfigurationStore, session: URLSession = .shared) -> CloudASRTranscribing {
|
||||
let strategy = CloudASRModelCatalog.strategy(for: store.providerId)
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
let asrModel = store.asrModel.isEmpty
|
||||
? CloudASRModelCatalog.defaultModel(for: providerId)
|
||||
: store.asrModel
|
||||
switch strategy {
|
||||
case .zhipuHotwords:
|
||||
return ZhipuCloudASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .alibabaVocabulary:
|
||||
return AlibabaFunASRClient(
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
persistence: store.cloudASRPersistence,
|
||||
session: session
|
||||
)
|
||||
case .prompt:
|
||||
return PromptCloudASRClient(
|
||||
providerId: store.providerId,
|
||||
baseURL: store.baseURL,
|
||||
apiKey: store.apiKey,
|
||||
model: CloudASRModelCatalog.defaultModel(for: store.providerId),
|
||||
providerId: providerId,
|
||||
baseURL: store.asrBaseURL,
|
||||
apiKey: store.asrApiKey,
|
||||
model: asrModel,
|
||||
session: session
|
||||
)
|
||||
case .localFallback:
|
||||
return UnsupportedCloudASRClient(providerId: store.providerId)
|
||||
return UnsupportedCloudASRClient(providerId: providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ public final class CloudASRService: ASRService, @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func bindClientIfNeeded() {
|
||||
let providerId = store.providerId
|
||||
let providerId = store.asrProviderId
|
||||
let strategy = CloudASRModelCatalog.strategy(for: providerId)
|
||||
lock.withLock {
|
||||
guard boundProviderId != providerId else { return }
|
||||
|
||||
@@ -70,7 +70,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
/// Fire-and-forget preparation for the host app. Safe to call repeatedly.
|
||||
/// Retries after exponential backoff when a prior attempt failed.
|
||||
public func prepareInBackgroundIfNeeded() {
|
||||
#if os(iOS)
|
||||
guard AppGroup.isAvailable else { return }
|
||||
#endif
|
||||
|
||||
let shouldStart = lock.withLock { () -> Bool in
|
||||
if case .preparing = state { return false }
|
||||
@@ -167,8 +169,8 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
throw PrepareError.missingPreparedArtifacts
|
||||
}
|
||||
|
||||
AppGroup.defaultsIfAvailable?.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||
AppGroup.defaultsIfAvailable?.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||
Self.persistenceDefaults.set(fingerprint, forKey: Storage.fingerprintKey)
|
||||
Self.persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.preparedAtKey)
|
||||
Self.clearRetryState()
|
||||
|
||||
lock.withLock {
|
||||
@@ -180,8 +182,9 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
return configuration
|
||||
}
|
||||
|
||||
// MARK: - DictationTranscriber factory
|
||||
// MARK: - DictationTranscriber factory (iOS host app)
|
||||
|
||||
#if os(iOS)
|
||||
public static func makeDictationTranscriber(
|
||||
locale: Locale,
|
||||
lmConfiguration: SFSpeechLanguageModel.Configuration?
|
||||
@@ -202,6 +205,34 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
attributeOptions: preset.attributeOptions
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Legacy Speech request (macOS Apple Speech fallback)
|
||||
|
||||
/// Up to 100 short phrases for `SFSpeechRecognitionRequest.contextualStrings`.
|
||||
public static func contextualStringsForRecognition(
|
||||
bias: LocalASRBiasPayload?,
|
||||
maxCount: Int = 100
|
||||
) -> [String] {
|
||||
guard let bias, !bias.hardHotwords.isEmpty else { return [] }
|
||||
return Array(bias.hardHotwords.prefix(max(1, maxCount)))
|
||||
}
|
||||
|
||||
/// Applies bundled CLM + optional contextual strings to a legacy on-device request.
|
||||
public static func applyCustomLanguageModel(
|
||||
to request: SFSpeechURLRecognitionRequest,
|
||||
locale: Locale,
|
||||
bias: LocalASRBiasPayload?
|
||||
) {
|
||||
request.requiresOnDeviceRecognition = true
|
||||
if let configuration = shared.configurationForTranscription(locale: locale) {
|
||||
request.customizedLanguageModel = configuration
|
||||
}
|
||||
let phrases = contextualStringsForRecognition(bias: bias)
|
||||
if !phrases.isEmpty {
|
||||
request.contextualStrings = phrases
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bundle / disk helpers
|
||||
|
||||
@@ -238,14 +269,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
static func preparedDirectoryURL() -> URL? {
|
||||
guard let container = FileManager.default.containerURL(
|
||||
if let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: AppGroup.identifier
|
||||
) else {
|
||||
) {
|
||||
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
#if os(macOS)
|
||||
guard let appSupport = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first else {
|
||||
return nil
|
||||
}
|
||||
let directory = container.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
let directory = appSupport
|
||||
.appendingPathComponent("OSGKeyboard", isDirectory: true)
|
||||
.appendingPathComponent(Storage.subdirectory, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
static func loadCachedConfigurationFromDisk() -> SFSpeechLanguageModel.Configuration? {
|
||||
@@ -279,7 +324,7 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static func storedFingerprint() -> String? {
|
||||
AppGroup.defaultsIfAvailable?.string(forKey: Storage.fingerprintKey)
|
||||
persistenceDefaults.string(forKey: Storage.fingerprintKey)
|
||||
}
|
||||
|
||||
private static func removeItemIfExists(at url: URL) throws {
|
||||
@@ -310,26 +355,28 @@ public final class CustomLanguageModelManager: @unchecked Sendable {
|
||||
|
||||
// MARK: - Retry / backoff
|
||||
|
||||
private static var persistenceDefaults: UserDefaults {
|
||||
AppGroup.defaultsIfAvailable ?? .standard
|
||||
}
|
||||
|
||||
private static func storedAttemptCount() -> Int {
|
||||
AppGroup.defaultsIfAvailable?.integer(forKey: Storage.attemptCountKey) ?? 0
|
||||
persistenceDefaults.integer(forKey: Storage.attemptCountKey)
|
||||
}
|
||||
|
||||
private static func storedLastFailureAt() -> TimeInterval? {
|
||||
let value = AppGroup.defaultsIfAvailable?.double(forKey: Storage.lastFailureAtKey) ?? 0
|
||||
let value = persistenceDefaults.double(forKey: Storage.lastFailureAtKey)
|
||||
return value > 0 ? value : nil
|
||||
}
|
||||
|
||||
private static func recordFailure() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
let nextAttempt = storedAttemptCount() + 1
|
||||
defaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||
defaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||
persistenceDefaults.set(nextAttempt, forKey: Storage.attemptCountKey)
|
||||
persistenceDefaults.set(Date().timeIntervalSince1970, forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
private static func clearRetryState() {
|
||||
guard let defaults = AppGroup.defaultsIfAvailable else { return }
|
||||
defaults.removeObject(forKey: Storage.attemptCountKey)
|
||||
defaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||
persistenceDefaults.removeObject(forKey: Storage.attemptCountKey)
|
||||
persistenceDefaults.removeObject(forKey: Storage.lastFailureAtKey)
|
||||
}
|
||||
|
||||
/// Returns false when retry budget is exhausted or backoff has not elapsed.
|
||||
|
||||
@@ -159,28 +159,78 @@ private final class FlowAudioProofStore: @unchecked Sendable {
|
||||
/// incoming buffer's format actually changes, so downsampling to the ASR target
|
||||
/// rate is always valid regardless of route churn.
|
||||
private final class AdaptiveDownsampler: @unchecked Sendable {
|
||||
// `AVAudioConverter` / `AVAudioFormat` are not `Sendable`, so the state and
|
||||
// the returned converter are guarded manually via the unchecked lock APIs.
|
||||
private let lock = OSAllocatedUnfairLock<(converter: AVAudioConverter, source: AVAudioFormat)?>(uncheckedState: nil)
|
||||
// `AVAudioConverter` / `AVAudioFormat` / `AVAudioPCMBuffer` are not
|
||||
// `Sendable`, so the state is guarded manually via the unchecked lock
|
||||
// APIs. The scratch output buffer is REUSED across tap callbacks —
|
||||
// allocating on the realtime audio thread risks priority inversion, and
|
||||
// taps on one bus are serialized, so a single scratch is safe as long as
|
||||
// callers copy its contents out before returning (AudioBufferSnapshot
|
||||
// does exactly that).
|
||||
private struct State {
|
||||
var converter: AVAudioConverter
|
||||
var source: AVAudioFormat
|
||||
var scratch: AVAudioPCMBuffer
|
||||
}
|
||||
|
||||
private let lock = OSAllocatedUnfairLock<State?>(uncheckedState: nil)
|
||||
let targetFormat: AVAudioFormat
|
||||
|
||||
/// Frame headroom for the reusable output buffer. Taps deliver ≤4096
|
||||
/// input frames; output frames = input × (16k / hardwareRate), which
|
||||
/// exceeds input only for sub-16 kHz hardware (rare telephony routes),
|
||||
/// so 2× the tap size covers every realistic ratio.
|
||||
private static let scratchCapacity: AVAudioFrameCount = 8_192
|
||||
|
||||
init(targetFormat: AVAudioFormat) {
|
||||
self.targetFormat = targetFormat
|
||||
}
|
||||
|
||||
/// Returns a converter valid for `sourceFormat`, rebuilding it lazily when
|
||||
/// the hardware route (and thus the buffer format) changes.
|
||||
func converter(for sourceFormat: AVAudioFormat) -> AVAudioConverter? {
|
||||
lock.withLockUnchecked { state in
|
||||
if let state, state.source == sourceFormat {
|
||||
return state.converter
|
||||
/// Downsamples `buffer` into the reusable scratch buffer and returns it,
|
||||
/// rebuilding the converter lazily when the hardware route (and thus the
|
||||
/// source format) changes. The returned buffer is only valid until the
|
||||
/// next call — copy its samples out synchronously.
|
||||
func convertReusingScratch(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
||||
let sourceFormat = buffer.format
|
||||
guard sourceFormat.sampleRate > 0 else { return nil }
|
||||
return lock.withLockUnchecked { state -> AVAudioPCMBuffer? in
|
||||
if state == nil || state!.source != sourceFormat {
|
||||
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat),
|
||||
let scratch = AVAudioPCMBuffer(
|
||||
pcmFormat: targetFormat,
|
||||
frameCapacity: Self.scratchCapacity
|
||||
) else {
|
||||
state = nil
|
||||
return nil
|
||||
}
|
||||
state = State(converter: converter, source: sourceFormat, scratch: scratch)
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: sourceFormat, to: targetFormat) else {
|
||||
state = nil
|
||||
return nil
|
||||
guard let current = state else { return nil }
|
||||
|
||||
let wanted = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
|
||||
)
|
||||
guard wanted > 0, wanted <= current.scratch.frameCapacity else { return nil }
|
||||
current.scratch.frameLength = 0
|
||||
|
||||
// ONE-SHOT input: the converter keeps pulling until the output
|
||||
// buffer's frameCapacity is full, and the scratch is deliberately
|
||||
// oversized — feeding the same tap buffer on every pull would
|
||||
// duplicate the audio ~6× (stuttering ASR input). After the
|
||||
// single feed we report "ran dry", so the expected status is
|
||||
// `.inputRanDry` (output not full), not `.haveData`.
|
||||
var provided = false
|
||||
var error: NSError?
|
||||
let status = current.converter.convert(to: current.scratch, error: &error) { _, outStatus in
|
||||
if provided {
|
||||
outStatus.pointee = .noDataNow
|
||||
return nil
|
||||
}
|
||||
provided = true
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
state = (converter, sourceFormat)
|
||||
return converter
|
||||
guard status != .error, error == nil, current.scratch.frameLength > 0 else { return nil }
|
||||
return current.scratch
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,6 +290,10 @@ public final class FlowContinuousCapture {
|
||||
private var didInstallTap = false
|
||||
private var isRunning = false
|
||||
private var isRebuilding = false
|
||||
private var interrupted = false
|
||||
/// When the engine last (re)activated — a freshly started engine has
|
||||
/// produced no frames yet and must not be misclassified as a zombie.
|
||||
private var lastActivationAt = Date.distantPast
|
||||
|
||||
private var routeObserver: NSObjectProtocol?
|
||||
private var interruptionObserver: NSObjectProtocol?
|
||||
@@ -250,6 +304,11 @@ public final class FlowContinuousCapture {
|
||||
|
||||
public var running: Bool { isRunning }
|
||||
|
||||
/// True between interruption `.began` and `.ended` (phone call, Siri).
|
||||
/// While set, `setActive(true)` is guaranteed to fail — owners should
|
||||
/// wait for `.ended` (which rebuilds the engine) instead of retrying.
|
||||
public var isInterrupted: Bool { interrupted }
|
||||
|
||||
/// True when the capture session flag, tap, and audio engine are all live.
|
||||
public var engineIsLive: Bool {
|
||||
isRunning && didInstallTap && audioEngine.isRunning
|
||||
@@ -264,9 +323,33 @@ public final class FlowContinuousCapture {
|
||||
/// Called on the main actor when `engineIsLive` may have changed.
|
||||
public var onEngineLiveChanged: ((Bool) -> Void)?
|
||||
|
||||
/// Called on the main actor when the system interrupted capture (phone
|
||||
/// call, Siri). The session owner should fail any mic-open utterance —
|
||||
/// audio frames stop arriving, so continuing to "record" only captures
|
||||
/// a silence gap the user cannot see.
|
||||
public var onInterruptionBegan: (() -> Void)?
|
||||
|
||||
/// Configure `.playAndRecord`, install a permanent input tap, start the engine.
|
||||
///
|
||||
/// Idempotent: "already running and healthy" is a warm-start fast path,
|
||||
/// while "already running but producing no audio" is a zombie state
|
||||
/// (force-quit relaunch, failed cold start, mediaserverd reset) that is
|
||||
/// torn down and rebuilt in place. It must never be a silent no-op —
|
||||
/// a `guard !isRunning` early-return here turned every cold-start retry
|
||||
/// into a guaranteed audio-proof timeout.
|
||||
public func start() throws {
|
||||
guard !isRunning else { return }
|
||||
if isRunning {
|
||||
let startedMomentsAgo = Date().timeIntervalSince(lastActivationAt) < 2
|
||||
if engineIsLive && (engineHasRecentAudio(maxAge: 2) || startedMomentsAgo) {
|
||||
// Healthy warm engine — or one so fresh it simply hasn't
|
||||
// produced its first frame yet (interleaved start attempts
|
||||
// land here; rebuilding a 100 ms-old engine only multiplies
|
||||
// audio-session churn in the fragile post-relaunch window).
|
||||
return
|
||||
}
|
||||
log.info("start(): zombie engine detected (running but no live audio) — forcing rebuild")
|
||||
stop()
|
||||
}
|
||||
audioProofStore.reset()
|
||||
try activateEngine()
|
||||
isRunning = true
|
||||
@@ -353,6 +436,7 @@ public final class FlowContinuousCapture {
|
||||
} catch {
|
||||
throw StartError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
lastActivationAt = Date()
|
||||
}
|
||||
|
||||
/// Tear down the engine and release the audio session.
|
||||
@@ -371,6 +455,7 @@ public final class FlowContinuousCapture {
|
||||
audioEngine.stop()
|
||||
}
|
||||
isRunning = false
|
||||
interrupted = false
|
||||
audioProofStore.reset()
|
||||
downsampler = nil
|
||||
targetFormat = nil
|
||||
@@ -384,6 +469,13 @@ public final class FlowContinuousCapture {
|
||||
|
||||
/// Re-activate capture after returning from background without
|
||||
/// reinstalling the tap (iOS may deactivate the audio session).
|
||||
///
|
||||
/// Doubles as the interruption-recovery probe: `setActive(true)` FAILS
|
||||
/// while a call/Siri interruption is live and succeeds once it ends, so a
|
||||
/// successful reassert proves the interruption is over. iOS does not
|
||||
/// guarantee delivery of `.ended` (commonly dropped when the app was
|
||||
/// suspended during the call), so this is the only reliable way to clear
|
||||
/// the `interrupted` latch in that case.
|
||||
@discardableResult
|
||||
public func reassertIfRunning() -> Bool {
|
||||
guard isRunning else { return false }
|
||||
@@ -395,6 +487,7 @@ public final class FlowContinuousCapture {
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .mixWithOthers]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
interrupted = false
|
||||
if !audioEngine.isRunning {
|
||||
try audioEngine.start()
|
||||
}
|
||||
@@ -415,7 +508,14 @@ public final class FlowContinuousCapture {
|
||||
if engineHasRecentAudio(maxAge: recentFrameMaxAge) {
|
||||
return true
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
} catch {
|
||||
// Cancelled — bail out instead of busy-spinning the main
|
||||
// actor for the rest of the window (a cancelled Task.sleep
|
||||
// returns immediately, starving concurrent start attempts).
|
||||
return false
|
||||
}
|
||||
}
|
||||
return engineHasRecentAudio(maxAge: recentFrameMaxAge)
|
||||
}
|
||||
@@ -497,8 +597,11 @@ public final class FlowContinuousCapture {
|
||||
switch type {
|
||||
case .began:
|
||||
log.info("Audio interruption began")
|
||||
interrupted = true
|
||||
notifyEngineLiveChanged()
|
||||
onInterruptionBegan?()
|
||||
case .ended:
|
||||
interrupted = false
|
||||
guard isRunning else { return }
|
||||
let shouldResume: Bool
|
||||
if let optionsRaw {
|
||||
@@ -627,26 +730,12 @@ public final class FlowContinuousCapture {
|
||||
audioProofStore.markFrameReceived()
|
||||
levelStore.update(from: buffer, barCount: FlowCaptureConstants.levelBarCount)
|
||||
|
||||
// Derive the converter from the *live* buffer format so a mid-session
|
||||
// route change (e.g. 48 kHz → 24 kHz) is handled transparently.
|
||||
let sourceFormat = buffer.format
|
||||
let targetFormat = downsampler.targetFormat
|
||||
guard sourceFormat.sampleRate > 0,
|
||||
let converter = downsampler.converter(for: sourceFormat) else { return }
|
||||
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * targetFormat.sampleRate / sourceFormat.sampleRate
|
||||
)
|
||||
guard outFrames > 0,
|
||||
let outBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: outFrames)
|
||||
else { return }
|
||||
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
guard status == .haveData, error == nil, outBuffer.frameLength > 0 else { return }
|
||||
// The downsampler derives its converter from the *live* buffer
|
||||
// format (mid-session route changes handled transparently) and
|
||||
// returns a REUSED scratch buffer — no per-callback allocation
|
||||
// on the realtime thread. The snapshot below copies the samples
|
||||
// out before the next tap callback can overwrite the scratch.
|
||||
guard let outBuffer = downsampler.convertReusingScratch(buffer) else { return }
|
||||
|
||||
let snapshot = AudioBufferSnapshot(buffer: outBuffer)
|
||||
guard !snapshot.samples.isEmpty else { return }
|
||||
|
||||
@@ -130,6 +130,12 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
public let localeId: String
|
||||
public let busyUtteranceId: UUID?
|
||||
public let sessionExpiresAt: TimeInterval?
|
||||
/// Host process generation that wrote this snapshot. A snapshot whose
|
||||
/// generation no longer matches `FlowSessionKeys.hostGeneration` was
|
||||
/// written by a dead process and is void immediately — no need to wait
|
||||
/// out the heartbeat-zombie window. Optional for wire compatibility with
|
||||
/// snapshots written before this field existed.
|
||||
public let hostGeneration: String?
|
||||
|
||||
public init(
|
||||
protocolVersion: Int = 1,
|
||||
@@ -142,7 +148,8 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
engineMode: String,
|
||||
localeId: String,
|
||||
busyUtteranceId: UUID? = nil,
|
||||
sessionExpiresAt: TimeInterval? = nil
|
||||
sessionExpiresAt: TimeInterval? = nil,
|
||||
hostGeneration: String? = nil
|
||||
) {
|
||||
self.protocolVersion = protocolVersion
|
||||
self.sessionId = sessionId
|
||||
@@ -155,6 +162,7 @@ public struct FlowReadySnapshot: Codable, Equatable, Sendable {
|
||||
self.localeId = localeId
|
||||
self.busyUtteranceId = busyUtteranceId
|
||||
self.sessionExpiresAt = sessionExpiresAt
|
||||
self.hostGeneration = hostGeneration
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,13 +274,27 @@ public enum FlowSessionBridge {
|
||||
store.set(readyAt, forKey: FlowSessionKeys.flowHostReadyAt)
|
||||
}
|
||||
} else {
|
||||
// Keep the not-ready payload. The keyboard needs `reason`
|
||||
// (recording / processing / waitingForAudioProof / …) to tell
|
||||
// "host is busy" apart from "host is still starting". Deleting
|
||||
// the payload here forced every mid-utterance ready=false into
|
||||
// a permanent orange `preparingSession` state.
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
}
|
||||
if let expires = snapshot.sessionExpiresAt {
|
||||
store.set(expires, forKey: FlowSessionKeys.flowSessionExpires)
|
||||
}
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
// Only a genuinely live host — ready, or actively serving an
|
||||
// utterance — may refresh the heartbeat here. A host stuck in a
|
||||
// failed cold start would otherwise keep "reviving" itself on every
|
||||
// engine-state flap, flickering the keyboard between reachable and
|
||||
// dead and postponing zombie-state cleanup indefinitely.
|
||||
let provesHostAlive = snapshot.ready
|
||||
|| snapshot.reason == .recording
|
||||
|| snapshot.reason == .processing
|
||||
if provesHostAlive {
|
||||
store.set(snapshot.heartbeatAt, forKey: FlowSessionKeys.flowHeartbeat)
|
||||
}
|
||||
flush(store)
|
||||
FlowSessionDarwin.postHostReadyChanged()
|
||||
}
|
||||
@@ -309,7 +331,8 @@ public enum FlowSessionBridge {
|
||||
heartbeatAt: now,
|
||||
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
|
||||
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
|
||||
sessionExpiresAt: expires
|
||||
sessionExpiresAt: expires,
|
||||
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
)
|
||||
if let data = encode(snapshot) {
|
||||
store.set(data, forKey: FlowSessionKeys.flowReadyPayload)
|
||||
@@ -418,6 +441,54 @@ public enum FlowSessionBridge {
|
||||
return staleness <= FlowSessionKeys.heartbeatStaleInterval
|
||||
}
|
||||
|
||||
// MARK: - Host process generation
|
||||
|
||||
/// Host app: rotate the per-process generation token. Call exactly once,
|
||||
/// as early as possible in the host launch path. Returns the previous
|
||||
/// generation (nil on first-ever launch) so the caller can log it.
|
||||
///
|
||||
/// Rationale: `applicationWillTerminate` is best-effort — it never runs
|
||||
/// when a *suspended* app is force-quit (the common case after a failed
|
||||
/// cold start). Instead of anchoring cleanup on a termination callback
|
||||
/// that may not fire, each launch proves the previous process is dead and
|
||||
/// voids whatever session state it left behind.
|
||||
@discardableResult
|
||||
public static func rotateHostGeneration(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let previous = store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
store.set(UUID().uuidString, forKey: FlowSessionKeys.hostGeneration)
|
||||
flush(store)
|
||||
return previous
|
||||
}
|
||||
|
||||
public static func currentHostGeneration(defaults: UserDefaults? = nil) -> String? {
|
||||
let store = resolvedDefaults(defaults)
|
||||
return store.string(forKey: FlowSessionKeys.hostGeneration)
|
||||
}
|
||||
|
||||
/// Host launch reconciliation: clear every piece of persisted session
|
||||
/// state a previous (dead) generation left behind. Unlike
|
||||
/// `clearFlowState()` this keeps `pendingHostBundleId` — on a keyboard
|
||||
/// `startflow` cold launch the scene delegate stores the host bundle id
|
||||
/// *before* the SwiftUI hierarchy (and thus the session manager) exists,
|
||||
/// and wiping it here would break the return-to-host affordance.
|
||||
public static func clearFlowStateOnHostLaunch(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(false, forKey: FlowSessionKeys.flowSessionActive)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowSessionExpires)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowHeartbeat)
|
||||
store.removeObject(forKey: FlowSessionKeys.keyboardRecordingState)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowCommandPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowResultPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowAckPayload)
|
||||
store.removeObject(forKey: FlowSessionKeys.flowReadyPayload)
|
||||
clearTranscription(defaults: store)
|
||||
store.removeObject(forKey: FlowSessionKeys.audioLevels)
|
||||
store.removeObject(forKey: FlowSessionKeys.lastActivityAt)
|
||||
clearHostReady(defaults: store, notify: false)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Host ready contract (host app → keyboard)
|
||||
|
||||
/// Host app: publish whether Flow can accept a new utterance right now.
|
||||
@@ -446,6 +517,13 @@ public enum FlowSessionBridge {
|
||||
let store = resolvedDefaults(defaults)
|
||||
if let snapshot = readySnapshot(defaults: store) {
|
||||
guard snapshot.ready else { return false }
|
||||
// Snapshot written by a dead host generation → void immediately,
|
||||
// without waiting out the heartbeat-zombie window.
|
||||
if let snapshotGeneration = snapshot.hostGeneration,
|
||||
let currentGeneration = store.string(forKey: FlowSessionKeys.hostGeneration),
|
||||
snapshotGeneration != currentGeneration {
|
||||
return false
|
||||
}
|
||||
guard isHostReachable(defaults: store) else { return false }
|
||||
if let readyAt = snapshot.readyAt {
|
||||
let skew = abs(snapshot.heartbeatAt - readyAt)
|
||||
|
||||
@@ -33,6 +33,11 @@ public enum FlowSessionKeys {
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||
public static let lastActivityAt = "flow.lastActivityAt"
|
||||
/// One-shot token rotated by every host-process launch. State written by
|
||||
/// a previous generation is void by definition — a fresh launch proves the
|
||||
/// previous process is dead, whether or not its `applicationWillTerminate`
|
||||
/// cleanup ever ran (it does NOT run when a suspended app is force-quit).
|
||||
public static let hostGeneration = "flow.hostGeneration.v1"
|
||||
|
||||
/// Heartbeat older than this → host is not actively reachable for recording.
|
||||
public static let heartbeatStaleInterval: TimeInterval = 3
|
||||
@@ -59,14 +64,23 @@ public enum FlowSessionKeys {
|
||||
public static let localASRWaitTimeout: TimeInterval = 120
|
||||
public static let cloudASRWaitTimeout: TimeInterval = 120
|
||||
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max length).
|
||||
/// Must cover worst-case post-stop backlog: remaining SpeechAnalyzer chunks
|
||||
/// plus cloud LLM polish (see `PolishingService.effectiveTimeout` cap).
|
||||
/// Hard cap on a single LLM polish request. `PolishingService`'s scaled
|
||||
/// per-request timeout clamps to this value, so it participates in the
|
||||
/// keyboard-watchdog budget below.
|
||||
public static let maxPolishTimeout: TimeInterval = 120
|
||||
|
||||
/// Extra slack for result serialization, cross-process propagation, and
|
||||
/// the host's own polling cadence.
|
||||
public static let resultDeliveryMargin: TimeInterval = 20
|
||||
|
||||
/// Keyboard watchdog after the user stops recording (not utterance max
|
||||
/// length). Derived from the host-side budget so it always outlasts the
|
||||
/// host's worst case (ASR drain wait + polish cap + margin) — hand-tuned
|
||||
/// constants drifted below the real host maximum, making the keyboard
|
||||
/// report a timeout for transcriptions that were still going to succeed.
|
||||
public static func keyboardResultTimeout(engineMode: String) -> TimeInterval {
|
||||
if engineMode == "local" {
|
||||
return 180
|
||||
}
|
||||
return 240
|
||||
let asrWait = engineMode == "local" ? localASRWaitTimeout : cloudASRWaitTimeout
|
||||
return asrWait + maxPolishTimeout + resultDeliveryMargin
|
||||
}
|
||||
|
||||
public enum RecordingState: String, Sendable, Equatable {
|
||||
|
||||
@@ -39,20 +39,48 @@ public final class AppCloudSync {
|
||||
?? SpeechHistoryCloudSync(kvs: kvs, makeStore: makeStore, historyDefaults: historyDefaults)
|
||||
}
|
||||
|
||||
/// Serializes external-change pulls: KVS posts change notifications in
|
||||
/// bursts (one per key at times), and overlapping pull-merge-apply runs
|
||||
/// can interleave their read/write phases. `wantsAnotherPull` coalesces
|
||||
/// every burst into at most one trailing re-pull.
|
||||
private var isPulling = false
|
||||
private var wantsAnotherPull = false
|
||||
|
||||
public func startObservingExternalChanges() {
|
||||
guard externalChangeObserver == nil else { return }
|
||||
externalChangeObserver = NotificationCenter.default.addObserver(
|
||||
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
) { [weak self] note in
|
||||
guard let self else { return }
|
||||
// Distinguish WHY the store changed. `.accountChange` means the
|
||||
// user switched iCloud accounts — the incoming values belong to a
|
||||
// DIFFERENT account and must not be merged into this one's data
|
||||
// (deleted-entry resurrection, foreign history, wrong settings).
|
||||
let reason = note.userInfo?[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int
|
||||
if reason == NSUbiquitousKeyValueStoreAccountChange {
|
||||
return
|
||||
}
|
||||
Task { @MainActor in
|
||||
await self.pullAllIfEnabled()
|
||||
await self.pullAllCoalesced()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pullAllCoalesced() async {
|
||||
guard !isPulling else {
|
||||
wantsAnotherPull = true
|
||||
return
|
||||
}
|
||||
isPulling = true
|
||||
defer { isPulling = false }
|
||||
repeat {
|
||||
wantsAnotherPull = false
|
||||
await pullAllIfEnabled()
|
||||
} while wantsAnotherPull
|
||||
}
|
||||
|
||||
public func stopObservingExternalChanges() {
|
||||
if let externalChangeObserver {
|
||||
NotificationCenter.default.removeObserver(externalChangeObserver)
|
||||
@@ -79,18 +107,27 @@ public final class AppCloudSync {
|
||||
}
|
||||
|
||||
/// Low-risk manual sync: pull remote changes, merge, then push local state.
|
||||
/// Each push runs independently — one payload failing must not abort the
|
||||
/// others (a too-large history would otherwise also kill the dictionary
|
||||
/// push). The first error is rethrown after every push has been tried.
|
||||
public func syncNow() async throws {
|
||||
let store = makeStore()
|
||||
await pullAllIfEnabled()
|
||||
|
||||
var firstError: Error?
|
||||
func attempt(_ body: () async throws -> Void) async {
|
||||
do { try await body() } catch { if firstError == nil { firstError = error } }
|
||||
}
|
||||
|
||||
if store.settingsICloudSyncEnabled {
|
||||
try await settingsSync.pushLocalIfEnabled()
|
||||
try await usageStatisticsSync.pushLocalIfEnabled()
|
||||
try await speechHistorySync.pushLocalIfEnabled()
|
||||
await attempt { try await settingsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await usageStatisticsSync.pushLocalIfEnabled() }
|
||||
await attempt { try await speechHistorySync.pushLocalIfEnabled() }
|
||||
}
|
||||
if store.personalDictionaryICloudSyncEnabled {
|
||||
try await dictionarySync.pushLocalIfEnabled(store.personalDictionary)
|
||||
await attempt { try await dictionarySync.pushLocalIfEnabled(store.personalDictionary) }
|
||||
}
|
||||
if let firstError { throw firstError }
|
||||
}
|
||||
|
||||
public var settingsSyncService: SettingsCloudSync { settingsSync }
|
||||
|
||||
@@ -24,8 +24,12 @@ public final class SpeechHistoryCloudSync {
|
||||
|
||||
public static let kvsKey = SyncedSpeechHistory.kvsKey
|
||||
public static let legacyKVSKey = SyncedSpeechHistory.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
/// The 1 MB iCloud KVS quota is for the WHOLE store, not per key.
|
||||
/// History and the personal dictionary must fit together (plus settings
|
||||
/// and usage stats) — once the store exceeds 1 MB, KVS rejects writes
|
||||
/// for ALL keys with `QuotaViolation` and every sync silently stops.
|
||||
/// Budget: ~400 KB history + ~400 KB dictionary + headroom for the rest.
|
||||
public static let maxPayloadBytes = 400_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
@@ -50,8 +54,16 @@ public final class SpeechHistoryCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
let local = SpeechHistoryStorage.load(from: historyDefaults())
|
||||
try push(local)
|
||||
// Read-merge-write: pushing the local view verbatim would overwrite
|
||||
// entries another device added since our last pull (KVS is
|
||||
// last-writer-wins with no server-side merge).
|
||||
let defaults = historyDefaults()
|
||||
let local = SpeechHistoryStorage.load(from: defaults)
|
||||
let merged = loadRemote().map { SyncedSpeechHistory.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote history.
|
||||
@@ -81,11 +93,34 @@ public final class SpeechHistoryCloudSync {
|
||||
}
|
||||
|
||||
public func push(_ history: SyncedSpeechHistory) throws {
|
||||
let data = try encode(history)
|
||||
let data = try encodeFittingBudget(history)
|
||||
kvs.set(data, forKey: Self.kvsKey)
|
||||
_ = kvs.synchronize()
|
||||
}
|
||||
|
||||
/// Encode, dropping the oldest entries until the payload fits the KVS
|
||||
/// budget. Without this, a history that once fit under the old 900 KB
|
||||
/// cap (300 long dictations easily exceed 400 KB) would make EVERY push
|
||||
/// throw forever — automatic pushes are fire-and-forget, so sync would
|
||||
/// just silently die with no way back short of clearing all history.
|
||||
/// Only the *uploaded* copy is trimmed; local history keeps its full
|
||||
/// 300 entries.
|
||||
func encodeFittingBudget(_ history: SyncedSpeechHistory) throws -> Data {
|
||||
var payload = history
|
||||
while true {
|
||||
do {
|
||||
return try encode(payload)
|
||||
} catch SpeechHistoryCloudSyncError.payloadTooLarge {
|
||||
guard payload.entries.count > 1 else { throw SpeechHistoryCloudSyncError.payloadTooLarge(byteCount: 0) }
|
||||
// Drop the oldest ~10% per pass; entries are kept
|
||||
// newest-first by the store, so trim from the tail.
|
||||
let sorted = payload.entries.sorted { $0.createdAt > $1.createdAt }
|
||||
let keep = max(1, sorted.count - max(1, sorted.count / 10))
|
||||
payload.entries = Array(sorted.prefix(keep))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func loadRemote() -> SyncedSpeechHistory? {
|
||||
if let data = kvs.data(forKey: Self.kvsKey) {
|
||||
return try? decode(data)
|
||||
|
||||
@@ -45,8 +45,16 @@ public final class UsageStatisticsCloudSync {
|
||||
public func pushLocalIfEnabled() async throws {
|
||||
let store = makeStore()
|
||||
guard store.settingsICloudSyncEnabled else { return }
|
||||
// Read-merge-write: this fires after every utterance, so pushing the
|
||||
// local view verbatim would clobber counter slices another device
|
||||
// advanced since our last pull (KVS is last-writer-wins). The
|
||||
// G-Counter merge makes the push commutative instead.
|
||||
let local = SyncedUsageStatisticsStorage.load(from: store.defaults)
|
||||
try push(local)
|
||||
let merged = loadRemote().map { SyncedUsageStatisticsV2.merge(local: local, remote: $0) } ?? local
|
||||
if merged != local {
|
||||
apply(merged, to: store.defaults, postNotification: true)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Called when settings sync is first enabled to union local + remote totals.
|
||||
|
||||
@@ -91,8 +91,11 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var micDisabled: Bool = false
|
||||
/// One-line helper shown above the mic while `micDisabled == true`.
|
||||
@Published public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||
@Published public var engineMode: String = "cloud"
|
||||
/// "local" → on-device ASR only. "cloud" → cloud ASR + LLM polish.
|
||||
/// Boot value must match the privacy-safe app default (`local`) so the
|
||||
/// keyboard never assumes the audio-uploading engine before the App
|
||||
/// Group config has been read.
|
||||
@Published public var engineMode: String = "local"
|
||||
/// v0.2.1 follow-up: derived — translation is on iff a target
|
||||
/// locale has been selected (mirrors `ProviderConfig.translationEnabled`
|
||||
/// so the chip / pipeline read the same source of truth).
|
||||
@@ -148,6 +151,46 @@ public final class KeyboardState: ObservableObject {
|
||||
case openSettings
|
||||
}
|
||||
|
||||
// MARK: - Temporary Flow debug (remove after orange-mic investigation)
|
||||
|
||||
/// Mirrored from `KeyboardFlowCoordinator` for the on-screen debug panel.
|
||||
@Published public var debugPendingFlowStart: Bool = false
|
||||
@Published public var debugFlowRecording: Bool = false
|
||||
@Published public var debugAwaitingFlowResult: Bool = false
|
||||
@Published public var debugHasFullAccess: Bool = false
|
||||
|
||||
/// Snapshot for the keyboard debug panel.
|
||||
public func makeFlowDebugRows(hasFullAccess: Bool) -> [FlowDebugRow] {
|
||||
debugHasFullAccess = hasFullAccess
|
||||
let micLabel: String = {
|
||||
switch micVoiceAvailability {
|
||||
case .ready: return "ready"
|
||||
case .recording: return "recording"
|
||||
case .processing: return "processing"
|
||||
case .unavailable(let reason):
|
||||
switch reason {
|
||||
case .hostNotReady: return "unavailable(hostNotReady)"
|
||||
case .preparingSession: return "unavailable(preparingSession)"
|
||||
case .noFullAccess: return "unavailable(noFullAccess)"
|
||||
case .appGroupUnavailable: return "unavailable(appGroupUnavailable)"
|
||||
case .missingAPIKey: return "unavailable(missingAPIKey)"
|
||||
}
|
||||
}
|
||||
}()
|
||||
let localRows: [FlowDebugRow] = [
|
||||
FlowDebugRow("mic", micLabel),
|
||||
FlowDebugRow("phase", String(describing: phase)),
|
||||
FlowDebugRow("pendingStart", debugPendingFlowStart ? "1" : "0"),
|
||||
FlowDebugRow("kb.recording", debugFlowRecording ? "1" : "0"),
|
||||
FlowDebugRow("kb.awaiting", debugAwaitingFlowResult ? "1" : "0"),
|
||||
FlowDebugRow("fullAccess", hasFullAccess ? "1" : "0"),
|
||||
FlowDebugRow("micDisabled", micDisabled ? "1" : "0"),
|
||||
FlowDebugRow("flowSessionPub", flowSessionActive ? "1" : "0"),
|
||||
FlowDebugRow("engine", engineMode)
|
||||
]
|
||||
return localRows + FlowDebugAppGroupSnapshot.rows()
|
||||
}
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
public var beginRecording: () -> Void = {}
|
||||
public var endRecording: () -> Void = {}
|
||||
|
||||
@@ -22,12 +22,29 @@ public enum Keychain: @unchecked Sendable {
|
||||
private static let legacyAccount = "current"
|
||||
private static let defaultProviderId = "openai"
|
||||
|
||||
private static func account(for providerId: String) -> String {
|
||||
private static func normalizedProviderId(_ providerId: String) -> String {
|
||||
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
||||
return "provider.\(normalized)"
|
||||
return trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
||||
}
|
||||
|
||||
/// LLM polish credentials (`provider.<id>`).
|
||||
private static func account(for providerId: String) -> String {
|
||||
"provider.\(normalizedProviderId(providerId))"
|
||||
}
|
||||
|
||||
/// Cloud ASR credentials (`asr.<id>`), independent from polish keys.
|
||||
private static func asrAccount(for providerId: String) -> String {
|
||||
"asr.\(normalizedProviderId(providerId))"
|
||||
}
|
||||
|
||||
// NOTE on kSecAttrAccessGroup: we deliberately rely on the DEFAULT
|
||||
// access group (the first entry in each target's keychain-access-groups,
|
||||
// which project.yml pins to `$(AppIdentifierPrefix)com.osgkeyboard.shared`
|
||||
// for every target). Setting the attribute explicitly would require the
|
||||
// team-prefixed string at runtime, which is not portably available
|
||||
// without injecting TeamID through the build system. If a SECOND access
|
||||
// group is ever added to any target, revisit this — reordered groups
|
||||
// would silently change which store these queries hit.
|
||||
private static func baseQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
@@ -43,6 +60,130 @@ public enum Keychain: @unchecked Sendable {
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
// MARK: - ASR keys
|
||||
|
||||
public static func asrApiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
||||
if preferICloudSync, let synced = readASRKey(providerId: providerId, synchronizable: true) {
|
||||
return synced
|
||||
}
|
||||
if let local = readASRKey(providerId: providerId, synchronizable: false) {
|
||||
return local
|
||||
}
|
||||
if preferICloudSync {
|
||||
return readASRKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func asrApiKeyOutcome(
|
||||
for providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> ReadOutcome {
|
||||
let first = readASRKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
||||
if case .found = first { return first }
|
||||
let second = readASRKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
||||
if case .found = second { return second }
|
||||
if case .unavailable = first { return first }
|
||||
if case .unavailable = second { return second }
|
||||
return .notFound
|
||||
}
|
||||
|
||||
public static func setASRAPIKey(_ key: String, for providerId: String, useICloudSync: Bool = false) throws {
|
||||
if key.isEmpty {
|
||||
try deleteASRAPIKey(for: providerId, useICloudSync: useICloudSync)
|
||||
return
|
||||
}
|
||||
if useICloudSync {
|
||||
try writeASRKey(key, providerId: providerId, synchronizable: true)
|
||||
try? deleteASRKey(providerId: providerId, synchronizable: false)
|
||||
} else {
|
||||
try writeASRKey(key, providerId: providerId, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
public static func deleteASRAPIKey(for providerId: String, useICloudSync: Bool = false) throws {
|
||||
try deleteASRKey(providerId: providerId, synchronizable: false)
|
||||
if useICloudSync {
|
||||
try deleteASRKey(providerId: providerId, synchronizable: true)
|
||||
}
|
||||
}
|
||||
|
||||
private static func readASRKey(providerId: String, synchronizable: Bool) -> String? {
|
||||
if case .found(let value) = readASRKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readASRKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
|
||||
var query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data,
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return .notFound
|
||||
}
|
||||
return .found(str)
|
||||
case errSecItemNotFound:
|
||||
// Pre-split installs stored one key under `provider.<id>` for both stages.
|
||||
return readKeyOutcome(providerId: providerId, synchronizable: synchronizable)
|
||||
default:
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] ASR Keychain read returned OSStatus \(status); reporting unavailable.")
|
||||
#endif
|
||||
return .unavailable(status)
|
||||
}
|
||||
}
|
||||
|
||||
private static func baseASRQuery(providerId: String, synchronizable: Bool) -> [String: Any] {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: asrAccount(for: providerId),
|
||||
kSecAttrSynchronizable as String: synchronizable ? kCFBooleanTrue! : kCFBooleanFalse!,
|
||||
]
|
||||
#if os(macOS)
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
return query
|
||||
}
|
||||
|
||||
private static func writeASRKey(_ key: String, providerId: String, synchronizable: Bool) throws {
|
||||
let data = Data(key.utf8)
|
||||
var baseQuery = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let updateAttrs: [String: Any] = [kSecValueData as String: data]
|
||||
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, updateAttrs as CFDictionary)
|
||||
switch updateStatus {
|
||||
case errSecSuccess:
|
||||
return
|
||||
case errSecItemNotFound:
|
||||
baseQuery[kSecValueData as String] = data
|
||||
baseQuery[kSecAttrAccessible as String] = synchronizable
|
||||
? kSecAttrAccessibleAfterFirstUnlock
|
||||
: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(baseQuery as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw KeychainError.unexpectedStatus(addStatus)
|
||||
}
|
||||
default:
|
||||
throw KeychainError.unexpectedStatus(updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
private static func deleteASRKey(providerId: String, synchronizable: Bool) throws {
|
||||
let query = baseASRQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess, status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LLM keys
|
||||
|
||||
public static func apiKey(for providerId: String, preferICloudSync: Bool = false) -> String? {
|
||||
if preferICloudSync, let synced = readKey(providerId: providerId, synchronizable: true) {
|
||||
return synced
|
||||
@@ -60,7 +201,43 @@ public enum Keychain: @unchecked Sendable {
|
||||
apiKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
/// Distinguishes "no key stored" from "keychain temporarily unreadable".
|
||||
public enum ReadOutcome: Equatable {
|
||||
case found(String)
|
||||
case notFound
|
||||
/// The keychain could not be read (e.g. `errSecInteractionNotAllowed`
|
||||
/// while the device is locked before first unlock). NOT the same as
|
||||
/// "no key configured" — telling the user to re-enter their key in
|
||||
/// this state would be wrong; the read succeeds once unlocked.
|
||||
case unavailable(OSStatus)
|
||||
}
|
||||
|
||||
/// Like `apiKey(for:)`, but reports WHY a key was not returned so
|
||||
/// callers can distinguish a missing key (user action needed) from a
|
||||
/// transiently locked keychain (retry later).
|
||||
public static func apiKeyOutcome(
|
||||
for providerId: String,
|
||||
preferICloudSync: Bool = false
|
||||
) -> ReadOutcome {
|
||||
let first = readKeyOutcome(providerId: providerId, synchronizable: preferICloudSync)
|
||||
if case .found = first { return first }
|
||||
let second = readKeyOutcome(providerId: providerId, synchronizable: !preferICloudSync)
|
||||
if case .found = second { return second }
|
||||
// Neither store had it: surface "unavailable" when either read was
|
||||
// blocked, since the key may well exist behind the lock.
|
||||
if case .unavailable = first { return first }
|
||||
if case .unavailable = second { return second }
|
||||
return .notFound
|
||||
}
|
||||
|
||||
private static func readKey(providerId: String, synchronizable: Bool) -> String? {
|
||||
if case .found(let value) = readKeyOutcome(providerId: providerId, synchronizable: synchronizable) {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func readKeyOutcome(providerId: String, synchronizable: Bool) -> ReadOutcome {
|
||||
var query = baseQuery(providerId: providerId, synchronizable: synchronizable)
|
||||
query[kSecReturnData as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
@@ -70,16 +247,16 @@ public enum Keychain: @unchecked Sendable {
|
||||
case errSecSuccess:
|
||||
guard let data = result as? Data,
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
return .notFound
|
||||
}
|
||||
return str
|
||||
return .found(str)
|
||||
case errSecItemNotFound:
|
||||
return nil
|
||||
return .notFound
|
||||
default:
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); treating as no key.")
|
||||
print("⚠️ [OSGKeyboard] Keychain read returned OSStatus \(status); reporting unavailable.")
|
||||
#endif
|
||||
return nil
|
||||
return .unavailable(status)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +370,13 @@ public enum Keychain: @unchecked Sendable {
|
||||
try? writeKey(local, providerId: provider.id, synchronizable: true)
|
||||
try? deleteKey(providerId: provider.id, synchronizable: false)
|
||||
}
|
||||
for provider in LLMProvider.asrSelectablePresets {
|
||||
guard let local = readASRKey(providerId: provider.id, synchronizable: false), !local.isEmpty else {
|
||||
continue
|
||||
}
|
||||
try? writeASRKey(local, providerId: provider.id, synchronizable: true)
|
||||
try? deleteASRKey(providerId: provider.id, synchronizable: false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding completion (reboot-durable flag)
|
||||
|
||||
+14
-3
@@ -26,8 +26,12 @@ public final class PersonalDictionaryCloudSync {
|
||||
|
||||
public static let kvsKey = PersonalDictionary.kvsKeyV2
|
||||
public static let legacyKVSKey = PersonalDictionary.legacyKVSKey
|
||||
/// Stay below the ~1 MB per-key KVS limit.
|
||||
public static let maxPayloadBytes = 900_000
|
||||
/// The 1 MB iCloud KVS quota covers the WHOLE store, not one key —
|
||||
/// this payload shares it with speech history, settings, and usage
|
||||
/// stats. Exceeding the total quota makes KVS reject writes for ALL
|
||||
/// keys (`QuotaViolation`), silently stopping every sync.
|
||||
/// Budget: ~400 KB dictionary + ~400 KB history + headroom.
|
||||
public static let maxPayloadBytes = 400_000
|
||||
|
||||
private let kvs: UbiquitousKeyValueStoreing
|
||||
private let makeStore: () -> AppGroupStore
|
||||
@@ -75,7 +79,14 @@ public final class PersonalDictionaryCloudSync {
|
||||
public func pushLocalIfEnabled(_ dictionary: PersonalDictionary) async throws {
|
||||
let store = makeStore()
|
||||
guard store.personalDictionaryICloudSyncEnabled else { return }
|
||||
try push(dictionary)
|
||||
// Read-merge-write: KVS is last-writer-wins; uploading the local
|
||||
// view verbatim would drop entries another device added since our
|
||||
// last pull. Tombstones in `merge` keep deletions intact.
|
||||
let merged = loadRemote().map { PersonalDictionary.merge(local: dictionary, remote: $0) } ?? dictionary
|
||||
if merged != dictionary {
|
||||
store.setPersonalDictionary(merged)
|
||||
}
|
||||
try push(merged)
|
||||
}
|
||||
|
||||
/// Enable sync: merge local + remote, persist locally, then upload.
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
// English dictation while halving the network round-trip.
|
||||
//
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → provider cloud ASR + user's cloud LLM
|
||||
// - `engineMode == "local"` → on-device ASR + built-in DeepSeek
|
||||
// - `engineMode == "cloud"` → user's cloud ASR + user's cloud LLM (independent)
|
||||
// - `engineMode == "local"` → on-device ASR + user's LLM (or built-in DeepSeek)
|
||||
// - Ultra-short, structure-free utterances skip the LLM entirely
|
||||
// - Cloud without API key → raw + `.missingAPIKey` warning
|
||||
// - Local without build key → raw + `.missingAPIKey` warning
|
||||
@@ -37,6 +37,10 @@ public actor PolishingService {
|
||||
/// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is
|
||||
/// still the repo placeholder, or cloud engine Keychain is empty.
|
||||
case missingAPIKey
|
||||
/// The keychain was unreadable (device locked before first unlock)
|
||||
/// — the key likely EXISTS; treat as transient, never as "please
|
||||
/// re-enter your API key".
|
||||
case keychainLocked
|
||||
}
|
||||
|
||||
/// v0.2.1: what the LLM should do with the raw transcript. The
|
||||
@@ -95,8 +99,13 @@ public actor PolishingService {
|
||||
return TranscriptPostProcessor.localClean(trimmed)
|
||||
}
|
||||
|
||||
if store.engineMode == "cloud", injectedClient == nil {
|
||||
guard !store.apiKey.isEmpty else {
|
||||
if injectedClient == nil {
|
||||
let providerId = Self.resolvedProviderId(store: store, providerIdOverride: providerIdOverride)
|
||||
let hasPolishKey = Self.hasPolishAPIKey(store: store, providerId: providerId)
|
||||
guard hasPolishKey else {
|
||||
if case .unavailable = Keychain.apiKeyOutcome(for: providerId, preferICloudSync: true) {
|
||||
throw PolishError.keychainLocked
|
||||
}
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
}
|
||||
@@ -150,10 +159,14 @@ public actor PolishingService {
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
guard PreconfiguredKeys.isDeepseekConfigured else {
|
||||
let userKey = store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !userKey.isEmpty {
|
||||
apiKey = userKey
|
||||
} else if PreconfiguredKeys.isDeepseekConfigured {
|
||||
apiKey = PreconfiguredKeys.deepseek
|
||||
} else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
apiKey = PreconfiguredKeys.deepseek
|
||||
} else {
|
||||
apiKey = store.apiKey
|
||||
}
|
||||
@@ -356,7 +369,11 @@ public actor PolishingService {
|
||||
/// (unpolished, unsegmented) ASR text.
|
||||
internal func effectiveTimeout(for text: String) -> TimeInterval {
|
||||
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
// The cap participates in the keyboard-watchdog budget — see
|
||||
// `FlowSessionKeys.keyboardResultTimeout`. Raising it here without
|
||||
// going through that constant would silently break the invariant
|
||||
// "keyboard timeout > host worst case".
|
||||
return min(max(scaled, timeout), FlowSessionKeys.maxPolishTimeout)
|
||||
}
|
||||
|
||||
internal static func resolvedProviderId(
|
||||
@@ -366,11 +383,25 @@ public actor PolishingService {
|
||||
if let providerIdOverride {
|
||||
return providerIdOverride
|
||||
}
|
||||
if store.engineMode == "local" {
|
||||
let id = store.providerId
|
||||
// Local installs without a user LLM key keep using the built-in DeepSeek path.
|
||||
if store.engineMode == "local",
|
||||
id != "deepseek",
|
||||
store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
PreconfiguredKeys.isDeepseekConfigured {
|
||||
return "deepseek"
|
||||
}
|
||||
let id = store.providerId
|
||||
return id == "deepseek" ? "openai" : id
|
||||
return id == "deepseek" && store.engineMode == "cloud" ? "openai" : id
|
||||
}
|
||||
|
||||
internal static func hasPolishAPIKey(store: any ConfigurationStore, providerId: String) -> Bool {
|
||||
if !store.apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return true
|
||||
}
|
||||
if providerId == "deepseek", PreconfiguredKeys.isDeepseekConfigured {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal static func resolveLLMEndpoint(
|
||||
@@ -396,6 +427,8 @@ extension PolishingService.PolishError: LocalizedError {
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (cloud: Settings API key; local: build configuration)."
|
||||
case .keychainLocked:
|
||||
return "API key unavailable while the device is locked — will work after unlock."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
let entry = SpeechHistoryEntry(text: trimmed, engineMode: engineMode)
|
||||
payload.entries.insert(entry, at: 0)
|
||||
payload.trimEntries()
|
||||
@@ -42,6 +43,7 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
}
|
||||
|
||||
public func delete(id: UUID) {
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
guard payload.entries.contains(where: { $0.id == id }) else { return }
|
||||
payload.deletedEntryIDs[id] = Date()
|
||||
payload.entries.removeAll { $0.id == id }
|
||||
@@ -51,12 +53,24 @@ public final class SpeechHistoryStore: ObservableObject {
|
||||
}
|
||||
|
||||
public func clearAll() {
|
||||
rebaseOnPersistedStateBeforeMutation()
|
||||
payload.recordClearAll()
|
||||
payload.updatedAt = Date()
|
||||
payload.pruneTombstonesIfNeeded()
|
||||
applyPayload(postCloudPush: true)
|
||||
}
|
||||
|
||||
/// Cloud pulls write the merged history to disk but only *schedule* the
|
||||
/// in-memory reload (the notification observer hops through a Task).
|
||||
/// Mutating a stale snapshot and saving it wholesale would erase whatever
|
||||
/// that merge just brought in — always rebase on the persisted state
|
||||
/// before mutating.
|
||||
private func rebaseOnPersistedStateBeforeMutation() {
|
||||
let disk = SpeechHistoryStorage.load(from: defaults)
|
||||
guard disk != payload else { return }
|
||||
payload = SyncedSpeechHistory.merge(local: payload, remote: disk)
|
||||
}
|
||||
|
||||
public func snapshot() -> SyncedSpeechHistory {
|
||||
payload
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// TranscriptionPolishFallback.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Shared polish-failure handling: conservative raw ASR cleanup plus
|
||||
// bilingual user-visible warnings (iOS Flow + macOS dictation).
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TranscriptionPolishFallback: Sendable {
|
||||
|
||||
public static func makeDelivery(
|
||||
rawText: String,
|
||||
error: Error,
|
||||
engineMode: String,
|
||||
chunkWarning: String?
|
||||
) -> TranscriptionDelivery {
|
||||
let fallbackText = TranscriptPostProcessor.cleanRawASRFallback(rawText)
|
||||
let warning = warning(for: error, engineMode: engineMode)
|
||||
?? degradedWarning()
|
||||
?? chunkWarning
|
||||
return TranscriptionDelivery(text: fallbackText, polishWarning: warning)
|
||||
}
|
||||
|
||||
public static func warning(for error: Error, engineMode: String) -> String? {
|
||||
if let polishError = error as? PolishingService.PolishError {
|
||||
switch polishError {
|
||||
case .missingAPIKey:
|
||||
if engineMode == "local" {
|
||||
return SharedL10n.string("flow.warning.localPolishUnavailable")
|
||||
}
|
||||
return SharedL10n.string("flow.warning.cloudPolishMissingKey")
|
||||
case .timeout, .keychainLocked:
|
||||
return degradedWarning()
|
||||
case .noTranscript:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if error is LLMError {
|
||||
return degradedWarning()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public static func degradedWarning() -> String? {
|
||||
SharedL10n.string("flow.warning.polishDegraded")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user