Files
OSGKeyboard/OSGKeyboardShared/Models/EditableInputReference.swift
T
Rocky fb97ec2937 feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
2026-08-16 12:07:06 +08:00

136 lines
5.0 KiB
Swift

// EditableInputReference.swift
// OSGKeyboard · Shared
//
// A short-lived, cross-process-safe reference to the last text the keyboard
// actually inserted. It is material for explicit voice editing, not history.
import Foundation
public struct EditableInputReference: Codable, Equatable, Sendable {
public static let schemaVersion = 1
public static let lifetime: TimeInterval = 10 * 60
/// Initial safety budget. Device benchmarks may lower this value.
public static let maxEditableGraphemes = 1_200
public let schemaVersion: Int
public let targetID: UUID
public let historyEntryID: UUID?
public let historyEntryRevision: Int64?
public let pendingHistoryMutationID: UUID?
public let displayText: String
/// Exact host-field insertion, including any leading separator.
public let insertedText: String
public let postInsertionFingerprint: String?
public let extensionInstanceID: UUID
public let observedDocumentRevision: Int64
public let createdAt: TimeInterval
public let expiresAt: TimeInterval
public init(
targetID: UUID = UUID(),
historyEntryID: UUID? = nil,
historyEntryRevision: Int64? = nil,
pendingHistoryMutationID: UUID? = nil,
displayText: String,
insertedText: String,
postInsertionFingerprint: String?,
extensionInstanceID: UUID,
observedDocumentRevision: Int64 = 0,
createdAt: TimeInterval = Date().timeIntervalSince1970
) {
self.schemaVersion = Self.schemaVersion
self.targetID = targetID
self.historyEntryID = historyEntryID
self.historyEntryRevision = historyEntryRevision
self.pendingHistoryMutationID = pendingHistoryMutationID
self.displayText = displayText
self.insertedText = insertedText
self.postInsertionFingerprint = postInsertionFingerprint
self.extensionInstanceID = extensionInstanceID
self.observedDocumentRevision = observedDocumentRevision
self.createdAt = createdAt
self.expiresAt = createdAt + Self.lifetime
}
public var isWithinLengthBudget: Bool {
!displayText.isEmpty && displayText.count <= Self.maxEditableGraphemes
}
public func isExpired(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
now >= expiresAt
}
/// Same-process verification uses the exact insertion kept in memory.
/// This path intentionally does not depend on the field fingerprint,
/// because UITextDocumentProxy may publish that context one callback after
/// the insertion while the in-memory record is already authoritative.
public func matchesLiveInsertion(
extensionInstanceID currentInstanceID: UUID,
lastInsertedText: String?,
contextBeforeInput: String?
) -> Bool {
!isExpired()
&& isWithinLengthBudget
&& extensionInstanceID == currentInstanceID
&& lastInsertedText == insertedText
&& contextBeforeInput?.hasSuffix(insertedText) == true
}
/// Rebuilt extensions must match the complete inserted string at the caret
/// and, when captured, the same field fingerprint; a suffix sample is insufficient.
public func isFullyVerified(
contextBeforeInput: String?,
fieldFingerprint: String?
) -> Bool {
guard !isExpired(), isWithinLengthBudget,
let contextBeforeInput,
contextBeforeInput.hasSuffix(insertedText) else {
return false
}
guard let expected = postInsertionFingerprint else { return true }
return fieldFingerprint == expected
}
}
/// App Group cache shared across extension instances. Loads evict references
/// after their TTL; callers must never save secure-field text because this is
/// cross-process persistence, not protected credential storage.
public enum EditableInputReferenceStore {
private static let key = "editLastInput.reference.v1"
public static func load(
defaults: UserDefaults? = nil,
now: TimeInterval = Date().timeIntervalSince1970
) -> EditableInputReference? {
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
let data = store.data(forKey: key),
let reference = try? JSONDecoder().decode(EditableInputReference.self, from: data)
else {
return nil
}
guard !reference.isExpired(at: now) else {
clear(defaults: store)
return nil
}
return reference
}
public static func save(
_ reference: EditableInputReference,
defaults: UserDefaults? = nil
) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
let data = try? JSONEncoder().encode(reference) else {
return
}
store.set(data, forKey: key)
store.synchronize()
}
public static func clear(defaults: UserDefaults? = nil) {
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
store.removeObject(forKey: key)
store.synchronize()
}
}