feat(keyboard): harden clipboard command and add What's New sheet
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.
This commit is contained in:
@@ -1,96 +1,46 @@
|
||||
// ClipboardCommandEligibility.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Tracks clipboard open-window eligibility (30s from first sighting of a
|
||||
// pasteboard change) for opportunity-read UI. Pure timing logic — no UIKit.
|
||||
// User-visible failure reasons when long-press clipboard command cannot start.
|
||||
// (30s eligibility window and continuous-rewrite sessions were removed.)
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct ClipboardCommandEligibility: Equatable, Sendable {
|
||||
public var changeCount: Int
|
||||
public var snapshot: String
|
||||
public var startedAt: TimeInterval
|
||||
/// Why a clipboard-command long-press did not start recording.
|
||||
public enum ClipboardCommandFailure: Equatable, Sendable {
|
||||
case pasteDenied
|
||||
case secureField
|
||||
case noFullAccess
|
||||
/// Host never confirmed capture (double-start / mic not ready / timeout).
|
||||
case prepareFailed
|
||||
case material(ClipboardMaterialFilter.Rejection)
|
||||
|
||||
public init(changeCount: Int, snapshot: String, startedAt: TimeInterval = Date().timeIntervalSince1970) {
|
||||
self.changeCount = changeCount
|
||||
self.snapshot = snapshot
|
||||
self.startedAt = startedAt
|
||||
}
|
||||
|
||||
public func isOpen(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
now - startedAt <= ClipboardMaterialFilter.eligibilityDuration
|
||||
}
|
||||
|
||||
public func remaining(at now: TimeInterval = Date().timeIntervalSince1970) -> TimeInterval {
|
||||
max(0, ClipboardMaterialFilter.eligibilityDuration - (now - startedAt))
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClipboardCommandEligibilityTracker: Sendable {
|
||||
/// Update eligibility from an opportunity-read sample.
|
||||
/// - Parameters:
|
||||
/// - changeCount: `UIPasteboard.general.changeCount`
|
||||
/// - rawText: pasteboard string (may be nil)
|
||||
/// - previous: last known eligibility
|
||||
/// - now: clock
|
||||
public static func refresh(
|
||||
changeCount: Int,
|
||||
rawText: String?,
|
||||
previous: ClipboardCommandEligibility?,
|
||||
now: TimeInterval = Date().timeIntervalSince1970
|
||||
) -> ClipboardCommandEligibility? {
|
||||
guard let rawText else { return nil }
|
||||
|
||||
if let previous, previous.changeCount == changeCount {
|
||||
return previous.isOpen(at: now) ? previous : nil
|
||||
}
|
||||
|
||||
switch ClipboardMaterialFilter.evaluate(rawText) {
|
||||
case .eligible(let snapshot):
|
||||
return ClipboardCommandEligibility(
|
||||
changeCount: changeCount,
|
||||
snapshot: snapshot,
|
||||
startedAt: now
|
||||
)
|
||||
case .rejected:
|
||||
return nil
|
||||
/// Localization key under the keyboard extension `Keyboard.strings` table.
|
||||
public var localizationKey: String {
|
||||
switch self {
|
||||
case .pasteDenied:
|
||||
return "keyboard.clipboard.reject.pasteDenied"
|
||||
case .secureField:
|
||||
return "keyboard.clipboard.reject.secureField"
|
||||
case .noFullAccess:
|
||||
return "keyboard.clipboard.reject.noFullAccess"
|
||||
case .prepareFailed:
|
||||
return "keyboard.clipboard.reject.prepareFailed"
|
||||
case .material(let rejection):
|
||||
switch rejection {
|
||||
case .empty:
|
||||
return "keyboard.clipboard.reject.empty"
|
||||
case .phoneOrNumeric:
|
||||
return "keyboard.clipboard.reject.phoneOrNumeric"
|
||||
case .emojiOrSymbolOnly:
|
||||
return "keyboard.clipboard.reject.emojiOrSymbolOnly"
|
||||
case .verificationCode:
|
||||
return "keyboard.clipboard.reject.verificationCode"
|
||||
case .tooShort:
|
||||
return "keyboard.clipboard.reject.tooShort"
|
||||
case .repetitiveSpam:
|
||||
return "keyboard.clipboard.reject.repetitiveSpam"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory clipboard-command task session (plan §8 layer B). Owned by the keyboard.
|
||||
public struct ClipboardCommandTaskSession: Equatable, Sendable {
|
||||
public var snapshot: String
|
||||
public var previousOutput: String?
|
||||
public var lastInsertedText: String?
|
||||
public var expiresAt: TimeInterval
|
||||
public var fieldFingerprint: String?
|
||||
|
||||
public init(
|
||||
snapshot: String,
|
||||
previousOutput: String? = nil,
|
||||
lastInsertedText: String? = nil,
|
||||
expiresAt: TimeInterval,
|
||||
fieldFingerprint: String? = nil
|
||||
) {
|
||||
self.snapshot = snapshot
|
||||
self.previousOutput = previousOutput
|
||||
self.lastInsertedText = lastInsertedText
|
||||
self.expiresAt = expiresAt
|
||||
self.fieldFingerprint = fieldFingerprint
|
||||
}
|
||||
|
||||
public func isActive(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
now <= expiresAt
|
||||
}
|
||||
|
||||
public mutating func refreshExpiry(at now: TimeInterval = Date().timeIntervalSince1970) {
|
||||
expiresAt = now + ClipboardMaterialFilter.sessionDuration
|
||||
}
|
||||
|
||||
public mutating func noteSuccessfulInsert(_ text: String, at now: TimeInterval = Date().timeIntervalSince1970) {
|
||||
lastInsertedText = text
|
||||
previousOutput = text
|
||||
refreshExpiry(at: now)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,12 @@ public enum ClipboardMaterialFilter: Sendable {
|
||||
|
||||
public static let minimumLength = 15
|
||||
public static let maxSnapshotLength = 3_000
|
||||
public static let eligibilityDuration: TimeInterval = 30
|
||||
public static let sessionDuration: TimeInterval = 30
|
||||
public static let longPressDuration: TimeInterval = 0.45
|
||||
/// After the host confirms real capture, keep recording at least this long
|
||||
/// before honoring finger-up (avoids near-silent cold-start tails).
|
||||
/// before honoring an explicit stop tap (avoids near-silent cold-start tails).
|
||||
public static let minimumRecordingAfterHostConfirm: TimeInterval = 0.70
|
||||
/// How long a clipboard-command failure tip stays above the mic.
|
||||
public static let failureHintDuration: TimeInterval = 2.5
|
||||
|
||||
public enum Rejection: String, Equatable, Sendable {
|
||||
case empty
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// ClipboardPreparingPolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure decisions for clipboard「准备录音…」so paste-alert restore / double-start
|
||||
// / host-failure recovery stay hermetic and regression-tested.
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Restore after paste-alert / cold-start recreate
|
||||
|
||||
public enum ClipboardRestoreAction: Equatable, Sendable {
|
||||
/// Mid-flight claim exists — reattach preparing/recording, never pressBegan again.
|
||||
case awaitExistingStart
|
||||
/// Sticky voice + snapshot only (e.g. after cold-start). Force voice; do **not** auto-record.
|
||||
case preferVoiceOnly
|
||||
/// Already in a live clipboard phase — only refresh UI / recover.
|
||||
case refreshOnly
|
||||
}
|
||||
|
||||
/// Whether a clipboard long-press may claim + start, or must warm the host first.
|
||||
public enum ClipboardHostGateAction: Equatable, Sendable {
|
||||
case startRecordingNow
|
||||
case openHostColdStart
|
||||
case waitForHost
|
||||
case ignore
|
||||
}
|
||||
|
||||
/// Mic chrome while a clipboard round is live.
|
||||
public enum ClipboardMicChrome: Equatable, Sendable {
|
||||
/// Grey / spinner / not tappable — waiting for host capture confirm.
|
||||
case preparingDisabled
|
||||
/// Blue recording chrome + side captions.
|
||||
case recordingBlue
|
||||
/// Not a clipboard recording chrome state.
|
||||
case none
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingPolicy: Sendable {
|
||||
|
||||
public static func restoreAction(
|
||||
hasStartIssued: Bool,
|
||||
phase: ClipboardPreparingPhase
|
||||
) -> ClipboardRestoreAction {
|
||||
switch phase {
|
||||
case .idle, .denied, .error:
|
||||
// Cold-start return has snapshot/preferVoice but no startIssued → voice only.
|
||||
return hasStartIssued ? .awaitExistingStart : .preferVoiceOnly
|
||||
case .requestingPermissions, .recording, .processing:
|
||||
return .refreshOnly
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the shared mic handoff decision onto clipboard (never auto-record after warm-up).
|
||||
public static func hostGateAction(
|
||||
micPressAction: FlowMicPressAction
|
||||
) -> ClipboardHostGateAction {
|
||||
switch micPressAction {
|
||||
case .startRecording:
|
||||
return .startRecordingNow
|
||||
case .openHostColdStart:
|
||||
return .openHostColdStart
|
||||
case .waitForHostReady:
|
||||
// Clipboard does not set recordWhenHostReady — user long-presses again.
|
||||
return .waitForHost
|
||||
case .ignore:
|
||||
return .ignore
|
||||
}
|
||||
}
|
||||
|
||||
public static func micChrome(
|
||||
isClipboardUtterance: Bool,
|
||||
phase: ClipboardPreparingPhase,
|
||||
awaitingHostConfirm: Bool
|
||||
) -> ClipboardMicChrome {
|
||||
guard isClipboardUtterance else { return .none }
|
||||
switch phase {
|
||||
case .requestingPermissions:
|
||||
return .preparingDisabled
|
||||
case .recording:
|
||||
return awaitingHostConfirm ? .preparingDisabled : .recordingBlue
|
||||
case .idle, .denied, .error, .processing:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stop while preparing
|
||||
|
||||
public static func stopWhilePreparing(
|
||||
awaitingHostConfirm: Bool
|
||||
) -> ClipboardPreparingStopAction {
|
||||
awaitingHostConfirm ? .abortPreparing : .requestStop
|
||||
}
|
||||
|
||||
// MARK: - Host moved on while preparing
|
||||
|
||||
public static func recoverWhilePreparing(
|
||||
awaitingHostConfirm: Bool,
|
||||
currentUtteranceId: UUID?,
|
||||
hostBusyUtteranceId: UUID?,
|
||||
hostReason: ClipboardHostBusyReason?,
|
||||
hasTerminalFailureForCurrent: Bool
|
||||
) -> ClipboardPreparingRecoverAction {
|
||||
guard awaitingHostConfirm else { return .none }
|
||||
|
||||
if hasTerminalFailureForCurrent {
|
||||
return .abortForHostFailure
|
||||
}
|
||||
|
||||
guard let hostReason, let busyId = hostBusyUtteranceId else {
|
||||
return .none
|
||||
}
|
||||
|
||||
switch hostReason {
|
||||
case .recording:
|
||||
if busyId == currentUtteranceId {
|
||||
return .confirmRecording
|
||||
}
|
||||
return .adoptSibling(busyId)
|
||||
case .processing:
|
||||
if busyId == currentUtteranceId {
|
||||
return .wait
|
||||
}
|
||||
return .adoptSibling(busyId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ensure at most one startRecording
|
||||
|
||||
public static func ensureStartAction(
|
||||
issuedUtteranceId: UUID?,
|
||||
isFlowRecording: Bool,
|
||||
currentUtteranceId: UUID?,
|
||||
hostBusyUtteranceId: UUID?,
|
||||
hostReason: ClipboardHostBusyReason?,
|
||||
hostReadyWithSession: Bool
|
||||
) -> ClipboardEnsureStartAction {
|
||||
guard let issued = issuedUtteranceId else { return .none }
|
||||
|
||||
if let busyId = hostBusyUtteranceId, let hostReason {
|
||||
switch hostReason {
|
||||
case .recording, .processing:
|
||||
return .adoptBusy(busyId, hostReason)
|
||||
}
|
||||
}
|
||||
|
||||
if isFlowRecording, currentUtteranceId == issued {
|
||||
return .alreadyInFlight
|
||||
}
|
||||
|
||||
if hostReadyWithSession {
|
||||
return .writeStart(issued)
|
||||
}
|
||||
|
||||
return .waitForHost
|
||||
}
|
||||
}
|
||||
|
||||
/// Keyboard phase subset relevant to clipboard prepare/restore.
|
||||
public enum ClipboardPreparingPhase: Equatable, Sendable {
|
||||
case idle
|
||||
case denied
|
||||
case error
|
||||
case requestingPermissions
|
||||
case recording
|
||||
case processing
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingStopAction: Equatable, Sendable {
|
||||
case abortPreparing
|
||||
case requestStop
|
||||
}
|
||||
|
||||
public enum ClipboardHostBusyReason: Equatable, Sendable {
|
||||
case recording
|
||||
case processing
|
||||
}
|
||||
|
||||
public enum ClipboardPreparingRecoverAction: Equatable, Sendable {
|
||||
case none
|
||||
case wait
|
||||
case confirmRecording
|
||||
case adoptSibling(UUID)
|
||||
case abortForHostFailure
|
||||
}
|
||||
|
||||
public enum ClipboardEnsureStartAction: Equatable, Sendable {
|
||||
case none
|
||||
case alreadyInFlight
|
||||
case adoptBusy(UUID, ClipboardHostBusyReason)
|
||||
case writeStart(UUID)
|
||||
case waitForHost
|
||||
}
|
||||
@@ -547,6 +547,20 @@ public enum FlowSessionBridge {
|
||||
setPendingHostBundleId(nil, defaults: defaults)
|
||||
}
|
||||
|
||||
/// True when a recent keyboard `startflow` arm should not be repeated.
|
||||
public static func isPiPArmInCooldown(defaults: UserDefaults? = nil) -> Bool {
|
||||
let store = resolvedDefaults(defaults)
|
||||
let last = store.double(forKey: FlowSessionKeys.lastPiPArmAttemptAt)
|
||||
guard last > 0 else { return false }
|
||||
return Date().timeIntervalSince1970 - last < FlowSessionKeys.pipArmCooldown
|
||||
}
|
||||
|
||||
public static func markPiPArmAttempt(defaults: UserDefaults? = nil) {
|
||||
let store = resolvedDefaults(defaults)
|
||||
store.set(Date().timeIntervalSince1970, forKey: FlowSessionKeys.lastPiPArmAttemptAt)
|
||||
flush(store)
|
||||
}
|
||||
|
||||
// MARK: - Session validity (keyboard)
|
||||
|
||||
/// True while the persistent PiP session contract is active.
|
||||
|
||||
@@ -33,6 +33,10 @@ public enum FlowSessionKeys {
|
||||
public static let audioLevels = "flow.audioLevels"
|
||||
/// Bundle id of the app that opened `osgkeyboard://startflow` (scheme D).
|
||||
public static let pendingHostBundleId = "flow.pendingHostBundleId"
|
||||
/// Wall-clock of the last keyboard→`startflow` PiP arm attempt (debounce re-jumps).
|
||||
public static let lastPiPArmAttemptAt = "flow.lastPiPArmAttemptAt.v1"
|
||||
/// Minimum gap between proactive / clipboard startflow jumps.
|
||||
public static let pipArmCooldown: TimeInterval = 45
|
||||
/// Wall-clock timestamp of the last utterance completion or session start.
|
||||
public static let lastActivityAt = "flow.lastActivityAt"
|
||||
/// One-shot token rotated by every host-process launch. State written by
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// KeyboardOpenSurfacePolicy.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure open-surface decision used by the keyboard extension. Extracted so
|
||||
// paste-alert sticky resume can be unit-tested without UIKit.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum KeyboardOpenSurfacePolicy: Sendable {
|
||||
/// Surface to show on the first frame of a keyboard presentation.
|
||||
public static func resolve(
|
||||
locksTypingSurface: Bool,
|
||||
clipboardCommandActive: Bool,
|
||||
stickyPreferVoice: Bool,
|
||||
preferred: KeyboardState.Surface
|
||||
) -> KeyboardState.Surface {
|
||||
if locksTypingSurface || clipboardCommandActive || stickyPreferVoice {
|
||||
return .voice
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
}
|
||||
@@ -130,10 +130,15 @@ public final class KeyboardState: ObservableObject {
|
||||
/// `true` while a cursor-drag pad is being pressed — drives the hint
|
||||
/// shown above the mic.
|
||||
@Published public var cursorDragActive: Bool = false
|
||||
/// Opportunity-read: clipboard text is eligible for long-press command mode.
|
||||
/// Idle affordance: pasteboard reports `hasStrings` (metadata only).
|
||||
@Published public var clipboardCommandEligible: Bool = false
|
||||
/// Active clipboard-command task (continuous rewrite window).
|
||||
@Published public var clipboardCommandSessionActive: Bool = false
|
||||
/// True while a clipboard-command utterance is in flight (preparing or recording).
|
||||
@Published public var clipboardCommandUtteranceActive: Bool = false
|
||||
/// True only while a clipboard-command utterance is in `.recording`
|
||||
/// (after host confirm) — drives blue mic chrome + side hints.
|
||||
@Published public var clipboardCommandRecording: Bool = false
|
||||
/// Transient tip after a failed clipboard long-press (auto-clears).
|
||||
@Published public var clipboardFailureHint: String? = nil
|
||||
/// Whether translate-and-polish is armed for the current engine.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
@@ -210,7 +215,6 @@ public final class KeyboardState: ObservableObject {
|
||||
public var endRecording: () -> Void = {}
|
||||
public var tapMic: () -> Void = {}
|
||||
public var beginClipboardCommand: () -> Void = {}
|
||||
public var endClipboardCommand: () -> Void = {}
|
||||
public var refreshClipboardEligibility: () -> Void = {}
|
||||
public var openSettings: () -> Void = {}
|
||||
public var startFlowSession: () -> Void = {}
|
||||
|
||||
Reference in New Issue
Block a user