fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands
Adapt typing/voice surfaces for iPad width and height, add the system globe key and last-input editing flow, harden host-only Rime deployment, and remove clipboard voice commands. Bump build to 61.
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// LastInputEditCoordinator.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Product workflow for explicit editing. Flow transport remains owned by
|
||||
// KeyboardFlowCoordinator; this coordinator owns only edit state and delivery.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
final class LastInputEditCoordinator {
|
||||
private let state: KeyboardState
|
||||
private let textInserter: KeyboardTextInserter
|
||||
private let beginFlow: (EditableInputReference) -> FlowUtteranceStartDisposition
|
||||
private let stopFlow: () -> Void
|
||||
private let abortFlow: () -> Void
|
||||
private let acknowledge: (FlowAck.DeliveryOutcome) -> Void
|
||||
private var hintTask: Task<Void, Never>?
|
||||
private var activeUtteranceID: UUID?
|
||||
private var reviewedUtteranceID: UUID?
|
||||
private var reviewedRevision: Int64?
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
textInserter: KeyboardTextInserter,
|
||||
beginFlow: @escaping (EditableInputReference) -> FlowUtteranceStartDisposition,
|
||||
stopFlow: @escaping () -> Void,
|
||||
abortFlow: @escaping () -> Void,
|
||||
acknowledge: @escaping (FlowAck.DeliveryOutcome) -> Void
|
||||
) {
|
||||
self.state = state
|
||||
self.textInserter = textInserter
|
||||
self.beginFlow = beginFlow
|
||||
self.stopFlow = stopFlow
|
||||
self.abortFlow = abortFlow
|
||||
self.acknowledge = acknowledge
|
||||
}
|
||||
|
||||
func begin() {
|
||||
switch state.editSession {
|
||||
case .inactive, .failed:
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
guard !state.micDisabled else {
|
||||
showHint(ExtL10n.string("keyboard.edit.error.llmUnavailable"))
|
||||
return
|
||||
}
|
||||
switch state.micVoiceAvailability {
|
||||
case .unavailable(.missingAPIKey):
|
||||
showHint(ExtL10n.string("keyboard.edit.error.llmUnavailable"))
|
||||
return
|
||||
case .unavailable(.noFullAccess):
|
||||
showHint(ExtL10n.string("keyboard.error.fullAccessRequired"))
|
||||
return
|
||||
case .unavailable(.appGroupUnavailable):
|
||||
showHint(ExtL10n.string("keyboard.error.appGroupCommunication"))
|
||||
return
|
||||
case .unavailable(.onboardingIncomplete):
|
||||
showHint(ExtL10n.string("keyboard.hint.finishSetupInApp"))
|
||||
return
|
||||
case .unavailable(.hostNotReady),
|
||||
.unavailable(.preparingSession),
|
||||
.ready,
|
||||
.recording,
|
||||
.processing:
|
||||
break
|
||||
}
|
||||
guard let reference = textInserter.editableReference() else {
|
||||
showHint(ExtL10n.string("keyboard.edit.error.noTarget"))
|
||||
return
|
||||
}
|
||||
start(reference)
|
||||
}
|
||||
|
||||
private func start(_ reference: EditableInputReference) {
|
||||
let source = EditSessionSource(reference: reference)
|
||||
state.lastTranscript = ""
|
||||
let disposition = beginFlow(reference)
|
||||
guard let utteranceID = disposition.utteranceID else {
|
||||
let message = startFailureMessage(for: disposition)
|
||||
state.editSession = .failed(source, message: message)
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
EditUsageMetricsStore.record(.failed)
|
||||
return
|
||||
}
|
||||
activeUtteranceID = utteranceID
|
||||
reviewedUtteranceID = nil
|
||||
reviewedRevision = nil
|
||||
state.editCanReplaceOriginal = true
|
||||
state.editSession = .preparing(source)
|
||||
state.phase = .requestingPermissions
|
||||
EditUsageMetricsStore.record(.entered)
|
||||
KeyboardHapticFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
OSGLog.keyboardExt.info(
|
||||
"edit.start issued utterance=\(self.activeUtteranceID?.uuidString.prefix(8) ?? "nil", privacy: .public)"
|
||||
)
|
||||
}
|
||||
|
||||
func hostRecordingConfirmed() {
|
||||
guard case .preparing(let source) = state.editSession else { return }
|
||||
state.editSession = .listening(source)
|
||||
state.phase = .recording
|
||||
KeyboardHapticFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
OSGLog.keyboardExt.info(
|
||||
"edit.hostRecording.confirmed utterance=\(self.activeUtteranceID?.uuidString.prefix(8) ?? "nil", privacy: .public)"
|
||||
)
|
||||
}
|
||||
|
||||
func stopListening() {
|
||||
guard case .listening(let source) = state.editSession else { return }
|
||||
state.editSession = .processing(source)
|
||||
state.phase = .processing
|
||||
stopFlow()
|
||||
}
|
||||
|
||||
func receive(result: FlowResult) {
|
||||
guard result.resolvedUtteranceMode == .editLastInput,
|
||||
result.utteranceId == activeUtteranceID,
|
||||
case .processing(let source) = state.editSession,
|
||||
reviewedUtteranceID != result.utteranceId
|
||||
|| reviewedRevision != result.revision,
|
||||
let output = result.text else {
|
||||
return
|
||||
}
|
||||
switch EditOutputValidator.validate(
|
||||
sourceText: source.reference.displayText,
|
||||
output: output
|
||||
) {
|
||||
case .success(let validated):
|
||||
reviewedUtteranceID = result.utteranceId
|
||||
reviewedRevision = result.revision
|
||||
let review = EditReview(
|
||||
source: source,
|
||||
resultText: validated,
|
||||
utteranceID: result.utteranceId
|
||||
)
|
||||
state.editCanReplaceOriginal =
|
||||
textInserter.editableReference()?.targetID == source.reference.targetID
|
||||
state.editSession = .review(review)
|
||||
state.phase = .processing
|
||||
KeyboardHapticFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
case .failure(.unchanged):
|
||||
fail(ExtL10n.string("keyboard.edit.error.unchanged"))
|
||||
acknowledge(.rejected)
|
||||
case .failure:
|
||||
fail(ExtL10n.string("keyboard.edit.error.processing"))
|
||||
acknowledge(.rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(_ message: String) {
|
||||
guard let source = state.editSession.source else {
|
||||
showHint(message)
|
||||
return
|
||||
}
|
||||
state.editSession = .failed(source, message: message)
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
EditUsageMetricsStore.record(.failed)
|
||||
activeUtteranceID = nil
|
||||
reviewedUtteranceID = nil
|
||||
reviewedRevision = nil
|
||||
}
|
||||
|
||||
func refreshContext() {
|
||||
guard let source = state.editSession.source else { return }
|
||||
state.editCanReplaceOriginal =
|
||||
textInserter.editableReference()?.targetID == source.reference.targetID
|
||||
}
|
||||
|
||||
func confirm() {
|
||||
guard case .review(let review) = state.editSession else { return }
|
||||
let shouldAppend = !state.editCanReplaceOriginal
|
||||
state.editSession = shouldAppend ? .appending(review) : .applying(review)
|
||||
let applied = textInserter.applyEdit(review, append: shouldAppend)
|
||||
guard applied else {
|
||||
state.editSession = .review(review)
|
||||
state.editCanReplaceOriginal = false
|
||||
return
|
||||
}
|
||||
acknowledge(shouldAppend ? .appended : .replaced)
|
||||
EditUsageMetricsStore.record(shouldAppend ? .appended : .replaced)
|
||||
state.editSession = .inactive
|
||||
state.editCanReplaceOriginal = false
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
activeUtteranceID = nil
|
||||
reviewedUtteranceID = nil
|
||||
reviewedRevision = nil
|
||||
KeyboardHapticFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
}
|
||||
|
||||
func close() {
|
||||
if state.editSession.review != nil {
|
||||
acknowledge(.rejected)
|
||||
} else {
|
||||
abortFlow()
|
||||
}
|
||||
state.editSession = .inactive
|
||||
state.editCanReplaceOriginal = false
|
||||
state.phase = .idle
|
||||
state.lastTranscript = ""
|
||||
EditUsageMetricsStore.record(.cancelled)
|
||||
activeUtteranceID = nil
|
||||
reviewedUtteranceID = nil
|
||||
reviewedRevision = nil
|
||||
}
|
||||
|
||||
func showAvailabilityHintAfterDictation() {
|
||||
guard textInserter.editableReference() != nil else { return }
|
||||
showHint(
|
||||
ExtL10n.string("keyboard.edit.hint.available"),
|
||||
isPositive: true,
|
||||
durationNanoseconds: 10_000_000_000
|
||||
)
|
||||
}
|
||||
|
||||
private func showHint(
|
||||
_ message: String,
|
||||
isPositive: Bool = false,
|
||||
durationNanoseconds: UInt64 = 2_500_000_000
|
||||
) {
|
||||
hintTask?.cancel()
|
||||
state.editHint = message
|
||||
state.editHintIsPositive = isPositive
|
||||
hintTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: durationNanoseconds)
|
||||
guard !Task.isCancelled,
|
||||
self?.state.editHint == message,
|
||||
self?.state.editHintIsPositive == isPositive else {
|
||||
return
|
||||
}
|
||||
self?.state.editHint = nil
|
||||
self?.state.editHintIsPositive = false
|
||||
}
|
||||
}
|
||||
|
||||
private func startFailureMessage(
|
||||
for disposition: FlowUtteranceStartDisposition
|
||||
) -> String {
|
||||
guard case .rejected(let reason) = disposition else {
|
||||
return ExtL10n.string("keyboard.edit.error.startTimeout")
|
||||
}
|
||||
switch reason {
|
||||
case .missingAPIKey:
|
||||
return ExtL10n.string("keyboard.edit.error.llmUnavailable")
|
||||
case .noFullAccess:
|
||||
return ExtL10n.string("keyboard.error.fullAccessRequired")
|
||||
case .appGroupUnavailable:
|
||||
return ExtL10n.string("keyboard.error.appGroupCommunication")
|
||||
case .onboardingIncomplete:
|
||||
return ExtL10n.string("keyboard.hint.finishSetupInApp")
|
||||
case .pipelineBusy:
|
||||
return ExtL10n.string("keyboard.edit.error.processing")
|
||||
case .hostUnavailable:
|
||||
return ExtL10n.string("keyboard.edit.error.startTimeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user