Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Resources/analytics-events.psv
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dictation_audio_route_recovery_timeout|default_input_class,default_output_class,
dictation_cancelled|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording
dictation_completed|auto_send,auto_send_block_reason,auto_send_expected,auto_send_key,default_input_class,default_output_class,delivery,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,target_confirmation_mode,trigger,was_recording,word_count_bucket
dictation_no_speech|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording
dictation_paste_retry_completed|reason,result
dictation_recording_too_short|default_input_class,default_output_class,duration_bucket,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording
dictation_start_failed|default_input_class,default_output_class,failure_kind,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,start_attempt_bucket,trigger,was_recording
dictation_started|default_input_class,default_output_class,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,trigger,was_recording
Expand Down
51 changes: 51 additions & 0 deletions Sources/Observability/DictationPasteRetryTelemetry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import Foundation

enum DictationPasteRetryTelemetry {
static let eventName = "dictation_paste_retry_completed"

@discardableResult
static func performUserRetry(
track: (String, [String: String]) -> Void = { event, properties in
AnalyticsReporter.track(event, properties: properties)
},
retry: () -> TextPasteOutcome
) -> TextPasteOutcome {
let outcome = retry()
track(eventName, properties(for: outcome))
return outcome
}

private static func properties(for outcome: TextPasteOutcome) -> [String: String] {
switch outcome {
case .pasted:
return ["result": "pasted"]
case .copied(_, reason: let reason):
return [
"reason": reason.analyticsName,
"result": "copied",
]
case .failed:
return [
"reason": "clipboard_unavailable",
"result": "failed",
]
}
}
}

private extension TextPasteCopyReason {
var analyticsName: String {
switch self {
case .accessibilityMissing:
return "accessibility_missing"
case .pasteEventCreationFailed:
return "paste_event_creation_failed"
case .focusChanged:
return "focus_changed"
case .pasteNotConfirmed:
return "paste_not_confirmed"
case .pasteConfirmationUnavailable, .pasteConfirmationUnavailableAutoSendEligible:
return "confirmation_unavailable"
}
}
}
156 changes: 139 additions & 17 deletions Sources/Support/ClipboardRestoringTextPaster.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ struct ClipboardPasteConfirmationDiagnostic: Equatable {
let context: [String: String]
}

private enum ClipboardPasteConfirmationWaitResult: Equatable {
case confirmed
case unconfirmed
case focusChanged
}

enum DictationTargetConfirmationMode: String, Equatable {
case textValue = "text_value"
case selectionRange = "selection_range"
Expand Down Expand Up @@ -560,6 +566,7 @@ final class ClipboardRestoringTextPaster {
private var clipboardAutoEnterReadinessTask: Task<Void, Never>?
private var clipboardAutoEnterReadyGeneration: Int?
private var pendingClipboardRestore: PendingClipboardRestore?
private var retainedClipboardRestoreForPasteRetry: PendingClipboardRestore?
private var temporaryPasteboardDataProvider: TemporaryPasteboardStringProvider?
private var pasteGeneration = 0
private(set) var lastConfirmationDiagnostic: ClipboardPasteConfirmationDiagnostic?
Expand All @@ -571,6 +578,11 @@ final class ClipboardRestoringTextPaster {

func cancelPendingClipboardRestore() {
restorePendingClipboardNow()
restoreRetainedClipboardNow()
}

func discardPasteRetry() {
retainedClipboardRestoreForPasteRetry = nil
}

func restorePendingClipboardNow() {
Expand Down Expand Up @@ -604,6 +616,45 @@ final class ClipboardRestoringTextPaster {
await waitForPendingClipboardRestore()
}

/// Retries only the paste gesture. It restores the clipboard snapshot retained by a
/// genuinely unconfirmed first attempt, then borrows the clipboard again. Auto Enter
/// is intentionally not part of this API, so an explicit retry cannot submit twice.
func retryPaste(
_ text: String,
target: DictationPasteTarget? = nil,
activationWait: TimeInterval = TranscriptedConstants.clipboardTargetActivationWait,
pasteboard: any ClipboardPasteboard = NSPasteboard.general,
accessibilityTrusted: () -> Bool = { AXIsProcessTrusted() },
requestAccessibilityTrust: () -> Void = {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
},
pasteDispatcher: @MainActor () -> Bool = postClipboardPasteShortcut,
pasteConfirmed: (@MainActor () -> Bool)? = nil,
targetIsFrontmost: (@MainActor () -> Bool)? = nil,
restoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreDelay,
fallbackRestoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreFallbackDelay,
pasteConfirmationWait: TimeInterval = TranscriptedConstants.clipboardPasteConfirmationWait
) -> TextPasteOutcome {
restoreRetainedClipboardBeforePasteRetry(text: text, pasteboard: pasteboard)
return paste(
text,
target: target,
activationWait: activationWait,
pasteboard: pasteboard,
accessibilityTrusted: accessibilityTrusted,
requestAccessibilityTrust: requestAccessibilityTrust,
pasteDispatcher: pasteDispatcher,
pasteConfirmed: pasteConfirmed,
targetIsFrontmost: targetIsFrontmost,
prepareForAutoSend: false,
retainClipboardForPasteRetry: false,
restoreDelay: restoreDelay,
fallbackRestoreDelay: fallbackRestoreDelay,
pasteConfirmationWait: pasteConfirmationWait
)
}

func paste(
_ text: String,
target: DictationPasteTarget? = nil,
Expand All @@ -616,12 +667,15 @@ final class ClipboardRestoringTextPaster {
},
pasteDispatcher: @MainActor () -> Bool = postClipboardPasteShortcut,
pasteConfirmed: (@MainActor () -> Bool)? = nil,
targetIsFrontmost: (@MainActor () -> Bool)? = nil,
prepareForAutoSend: Bool = false,
retainClipboardForPasteRetry: Bool = true,
restoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreDelay,
fallbackRestoreDelay: UInt64 = TranscriptedConstants.clipboardRestoreFallbackDelay,
pasteConfirmationWait: TimeInterval = TranscriptedConstants.clipboardPasteConfirmationWait
) -> TextPasteOutcome {
lastConfirmationDiagnostic = nil
discardPasteRetry()
restorePendingClipboardBeforeNewPaste()

if let target,
Expand Down Expand Up @@ -686,6 +740,9 @@ final class ClipboardRestoringTextPaster {
}

let confirmationUnavailable = pasteConfirmed == nil && accessibilityConfirmation?.canObservePaste != true
let targetRemainsFrontmost = targetIsFrontmost ?? {
target?.matchesCurrentFrontmostApp() != false
}
let confirmPasteReceived = pasteConfirmed ?? {
if accessibilityConfirmation?.confirmationMode(
text,
Expand All @@ -698,12 +755,12 @@ final class ClipboardRestoringTextPaster {
return false
}

let pasteWasConfirmed = waitForPasteConfirmation(
target: target,
let pasteConfirmationResult = waitForPasteConfirmation(
targetIsFrontmost: targetRemainsFrontmost,
pasteConfirmed: confirmPasteReceived,
timeout: pasteConfirmationWait
)
guard pasteWasConfirmed else {
guard pasteConfirmationResult == .confirmed else {
var diagnostics = accessibilityConfirmation?.diagnosticsContext(
clipboardReadAt: temporaryProvider?.firstReadAt,
pasteDispatchedAt: pasteDispatchedAt
Expand All @@ -714,17 +771,26 @@ final class ClipboardRestoringTextPaster {
"target_selection_observable": "false",
"target_text_observable": "false",
]
diagnostics["target_still_frontmost"] = "\(target?.matchesCurrentFrontmostApp() != false)"
let targetStillFrontmost = pasteConfirmationResult == .unconfirmed
diagnostics["target_still_frontmost"] = "\(targetStillFrontmost)"
lastConfirmationDiagnostic = ClipboardPasteConfirmationDiagnostic(
event: "dictation_paste_confirmation_diagnostics",
context: diagnostics
)
let clipboardReadAfterDispatch = (temporaryProvider?.firstReadAt ?? 0) >= pasteDispatchedAt
let targetStillFrontmost = target != nil && target?.matchesCurrentFrontmostApp() == true
if !targetStillFrontmost {
guard leaveTemporaryClipboardAvailable() else {
return .failed("Couldn't keep the dictation copied after focus moved. The dictation was saved, but paste-back did not run.")
}
return .copied(
"Focus moved before Transcripted could confirm paste. The text is on your clipboard — press ⌘V.",
reason: .focusChanged
)
}
if confirmationUnavailable,
prepareForAutoSend,
clipboardReadAfterDispatch,
targetStillFrontmost {
target != nil {
scheduleClipboardRestore(
savedItems,
temporaryString: text,
Expand All @@ -738,7 +804,9 @@ final class ClipboardRestoringTextPaster {
reason: .pasteConfirmationUnavailableAutoSendEligible
)
}
guard leaveTemporaryClipboardAvailable() else {
guard leaveTemporaryClipboardAvailable(
retainingRestoreForPasteRetry: !confirmationUnavailable && retainClipboardForPasteRetry
) else {
return .failed("Couldn't keep the dictation copied after paste-back was unconfirmed. The dictation was saved, but paste-back did not run.")
}
if confirmationUnavailable {
Expand Down Expand Up @@ -816,7 +884,9 @@ final class ClipboardRestoringTextPaster {
)
}

private func leaveTemporaryClipboardAvailable() -> Bool {
private func leaveTemporaryClipboardAvailable(
retainingRestoreForPasteRetry: Bool = false
) -> Bool {
guard let pendingClipboardRestore else {
clearPendingClipboardRestore(restore: false)
return true
Expand All @@ -833,7 +903,55 @@ final class ClipboardRestoringTextPaster {
if pasteboard.changeCount != temporaryChangeCount {
return true
}
return copyTextToClipboard(temporaryString, to: pasteboard)
guard copyTextToClipboard(temporaryString, to: pasteboard) else {
return false
}
if retainingRestoreForPasteRetry {
retainedClipboardRestoreForPasteRetry = PendingClipboardRestore(
savedItems: pendingClipboardRestore.savedItems,
temporaryString: temporaryString,
temporaryChangeCount: pasteboard.changeCount,
pasteboard: pasteboard,
generation: pasteGeneration
)
}
return true
}

private func restoreRetainedClipboardBeforePasteRetry(
text: String,
pasteboard: any ClipboardPasteboard
) {
guard let retained = retainedClipboardRestoreForPasteRetry else { return }
retainedClipboardRestoreForPasteRetry = nil
guard retained.temporaryString == text,
(retained.pasteboard as AnyObject) === (pasteboard as AnyObject),
pasteboard.changeCount == retained.temporaryChangeCount,
pasteboard.string(forType: .string) == text else {
return
}
restorePasteboardItems(
retained.savedItems,
temporaryString: retained.temporaryString,
temporaryChangeCount: retained.temporaryChangeCount,
to: pasteboard
)
}

private func restoreRetainedClipboardNow() {
guard let retained = retainedClipboardRestoreForPasteRetry else { return }
retainedClipboardRestoreForPasteRetry = nil
let pasteboard = retained.pasteboard
guard pasteboard.changeCount == retained.temporaryChangeCount,
pasteboard.string(forType: .string) == retained.temporaryString else {
return
}
restorePasteboardItems(
retained.savedItems,
temporaryString: retained.temporaryString,
temporaryChangeCount: retained.temporaryChangeCount,
to: pasteboard
)
}

private func scheduleClipboardAutoEnterReadiness(generation: Int, delay: UInt64) {
Expand Down Expand Up @@ -943,25 +1061,29 @@ final class ClipboardRestoringTextPaster {
}

private func waitForPasteConfirmation(
target: DictationPasteTarget?,
targetIsFrontmost: @MainActor () -> Bool,
pasteConfirmed: @MainActor () -> Bool,
timeout: TimeInterval
) -> Bool {
guard target?.matchesCurrentFrontmostApp() != false else { return false }
) -> ClipboardPasteConfirmationWaitResult {
guard targetIsFrontmost() else { return .focusChanged }
if pasteConfirmed() {
return true
return targetIsFrontmost() ? .confirmed : .focusChanged
}
guard timeout > 0 else { return false }
guard targetIsFrontmost() else { return .focusChanged }
guard timeout > 0 else { return .unconfirmed }

let start = Date()
while Date().timeIntervalSince(start) < timeout {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
guard target?.matchesCurrentFrontmostApp() != false else { return false }
guard targetIsFrontmost() else { return .focusChanged }
if pasteConfirmed() {
return true
return targetIsFrontmost() ? .confirmed : .focusChanged
}
}
return pasteConfirmed()
guard targetIsFrontmost() else { return .focusChanged }
let confirmed = pasteConfirmed()
guard targetIsFrontmost() else { return .focusChanged }
return confirmed ? .confirmed : .unconfirmed
}

func snapshotPasteboardItems(from pasteboard: any ClipboardPasteboard) -> PasteboardSnapshot {
Expand Down
Loading
Loading