fb97ec2937
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
136 lines
5.0 KiB
Swift
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()
|
|
}
|
|
}
|