9da32b81e9
Stabilize clipboard long-press prepare/resume across paste alerts and cold start, add an in-app release-notes sheet with remote bilingual HTML, localize typing input settings, and bump build to 55.
108 lines
4.8 KiB
Swift
108 lines
4.8 KiB
Swift
// ClipboardCommandResume.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Sticky App Group flags so the system「允许粘贴」alert can dismiss / recreate
|
|
// the keyboard extension without losing "stay on voice + clipboard chrome".
|
|
|
|
import Foundation
|
|
|
|
public enum ClipboardCommandResume: Sendable {
|
|
private enum Key {
|
|
/// Prefer voice surface on the next keyboard presentation.
|
|
static let preferVoice = "clipboardCommand.preferVoice.v1"
|
|
/// Frozen snapshot captured before / during paste alert (optional).
|
|
static let snapshot = "clipboardCommand.pendingSnapshot.v1"
|
|
/// Wall time when prefer-voice was marked (drop stale flags).
|
|
static let markedAt = "clipboardCommand.preferVoiceAt.v1"
|
|
/// Utterance id already sent as startRecording — blocks duplicate starts.
|
|
static let startIssuedUtterance = "clipboardCommand.startIssuedUtterance.v1"
|
|
}
|
|
|
|
/// How long a sticky prefer-voice / snapshot remains valid.
|
|
public static let stickyTTL: TimeInterval = 120
|
|
/// Max time to wait in「准备录音…」for host confirm before failing closed.
|
|
public static let preparingTimeout: TimeInterval = 6
|
|
|
|
public static func markPreferVoice(defaults: UserDefaults? = nil) {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
|
store.set(true, forKey: Key.preferVoice)
|
|
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
|
// Paste alert often jetsams the extension — flush before we block on
|
|
// UIPasteboard.string so a recreated process still sees prefer-voice.
|
|
store.synchronize()
|
|
}
|
|
|
|
public static func storeSnapshot(_ text: String, defaults: UserDefaults? = nil) {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
store.set(trimmed, forKey: Key.snapshot)
|
|
store.set(true, forKey: Key.preferVoice)
|
|
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
|
store.synchronize()
|
|
}
|
|
|
|
public static func markStartIssued(_ utteranceId: UUID, defaults: UserDefaults? = nil) {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
|
store.set(utteranceId.uuidString, forKey: Key.startIssuedUtterance)
|
|
store.set(true, forKey: Key.preferVoice)
|
|
store.set(Date().timeIntervalSince1970, forKey: Key.markedAt)
|
|
store.synchronize()
|
|
}
|
|
|
|
public static func startIssuedUtteranceId(defaults: UserDefaults? = nil) -> UUID? {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
|
pruneIfStale(store: store)
|
|
guard let raw = store.string(forKey: Key.startIssuedUtterance) else { return nil }
|
|
return UUID(uuidString: raw)
|
|
}
|
|
|
|
public static func hasStartIssued(defaults: UserDefaults? = nil) -> Bool {
|
|
startIssuedUtteranceId(defaults: defaults) != nil
|
|
}
|
|
|
|
public static func clear(defaults: UserDefaults? = nil) {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
|
store.removeObject(forKey: Key.preferVoice)
|
|
store.removeObject(forKey: Key.snapshot)
|
|
store.removeObject(forKey: Key.markedAt)
|
|
store.removeObject(forKey: Key.startIssuedUtterance)
|
|
store.synchronize()
|
|
}
|
|
|
|
public static func shouldPreferVoice(defaults: UserDefaults? = nil) -> Bool {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return false }
|
|
pruneIfStale(store: store)
|
|
return store.bool(forKey: Key.preferVoice)
|
|
}
|
|
|
|
public static func pendingSnapshot(defaults: UserDefaults? = nil) -> String? {
|
|
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return nil }
|
|
pruneIfStale(store: store)
|
|
guard store.bool(forKey: Key.preferVoice) else { return nil }
|
|
return store.string(forKey: Key.snapshot)
|
|
}
|
|
|
|
private static func pruneIfStale(store: UserDefaults) {
|
|
let markedAt = store.double(forKey: Key.markedAt)
|
|
guard markedAt > 0 else {
|
|
// Legacy / incomplete write — drop.
|
|
if store.object(forKey: Key.preferVoice) != nil
|
|
|| store.object(forKey: Key.startIssuedUtterance) != nil {
|
|
store.removeObject(forKey: Key.preferVoice)
|
|
store.removeObject(forKey: Key.snapshot)
|
|
store.removeObject(forKey: Key.startIssuedUtterance)
|
|
store.synchronize()
|
|
}
|
|
return
|
|
}
|
|
if Date().timeIntervalSince1970 - markedAt > stickyTTL {
|
|
store.removeObject(forKey: Key.preferVoice)
|
|
store.removeObject(forKey: Key.snapshot)
|
|
store.removeObject(forKey: Key.markedAt)
|
|
store.removeObject(forKey: Key.startIssuedUtterance)
|
|
store.synchronize()
|
|
}
|
|
}
|
|
}
|