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:
Rocky
2026-08-08 00:20:42 +08:00
parent f197b68573
commit 9da32b81e9
45 changed files with 3670 additions and 422 deletions
@@ -23,10 +23,11 @@ public struct RecordButton: View {
public let level: Double
public let remainingSeconds: Int?
public let isEnabled: Bool
/// When true (and `phase == .recording`), use blue clipboard-command chrome.
public let isClipboardCommandRecording: Bool
public let onToggle: () -> Void
/// When non-nil, a 0.45s hold starts clipboard-command recording instead of toggle.
/// When non-nil, a 0.45s hold starts clipboard-command recording (tap again to stop).
public let onClipboardLongPressBegan: (() -> Void)?
public let onClipboardLongPressEnded: (() -> Void)?
@State private var breath = false
@State private var longPressArmed = false
@@ -36,17 +37,17 @@ public struct RecordButton: View {
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
isClipboardCommandRecording: Bool = false,
onToggle: @escaping () -> Void,
onClipboardLongPressBegan: (() -> Void)? = nil,
onClipboardLongPressEnded: (() -> Void)? = nil
onClipboardLongPressBegan: (() -> Void)? = nil
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.isClipboardCommandRecording = isClipboardCommandRecording
self.onToggle = onToggle
self.onClipboardLongPressBegan = onClipboardLongPressBegan
self.onClipboardLongPressEnded = onClipboardLongPressEnded
}
private var isUrgent: Bool {
@@ -54,6 +55,17 @@ public struct RecordButton: View {
return remainingSeconds <= 10
}
/// Active recording tint: blue for clipboard-command, red for dictation.
private var recordingTint: Color {
isClipboardCommandRecording ? palette.recordBlue : palette.recordRed
}
private var waveformColor: Color {
isClipboardCommandRecording
? Color(red: 0.78, green: 0.88, blue: 1.0)
: Color(red: 1.0, green: 0.78, blue: 0.78)
}
private enum Layout {
static let disc: CGFloat = 95
static let outerRing: CGFloat = 106
@@ -64,16 +76,17 @@ public struct RecordButton: View {
public var body: some View {
ZStack {
Circle()
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.stroke(recordingTint.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.frame(width: Layout.breathRing, height: Layout.breathRing)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
.animation(colorTransition, value: isClipboardCommandRecording)
Circle()
.fill(
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
colors: [recordingTint.opacity(0.55), .clear],
center: .center,
startRadius: 46,
endRadius: 92
@@ -84,6 +97,7 @@ public struct RecordButton: View {
.blur(radius: 18)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
.animation(colorTransition, value: isClipboardCommandRecording)
Circle()
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
@@ -114,7 +128,7 @@ public struct RecordButton: View {
}
WaveformView(
level: level,
color: Color(red: 1.0, green: 0.78, blue: 0.78),
color: waveformColor,
active: true
)
.frame(width: 73, height: 32)
@@ -137,6 +151,7 @@ public struct RecordButton: View {
.frame(width: Layout.disc, height: Layout.disc)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
.animation(colorTransition, value: isClipboardCommandRecording)
}
.contentShape(Circle())
.modifier(
@@ -146,8 +161,7 @@ public struct RecordButton: View {
supportsClipboardLongPress: onClipboardLongPressBegan != nil,
longPressArmed: $longPressArmed,
onToggle: onToggle,
onClipboardLongPressBegan: onClipboardLongPressBegan,
onClipboardLongPressEnded: onClipboardLongPressEnded
onClipboardLongPressBegan: onClipboardLongPressBegan
)
)
.onAppear { breath = (phase == .recording) }
@@ -160,6 +174,11 @@ public struct RecordButton: View {
.accessibilityLabel(Text(SharedL10n.string("keyboard.tapToTalkA11y")))
}
/// Red blue mode switch (~0.25s).
private var colorTransition: Animation {
.easeInOut(duration: 0.25)
}
private var isIdle: Bool {
switch phase {
case .idleReady, .idleUnavailable:
@@ -179,8 +198,8 @@ public struct RecordButton: View {
switch phase {
case .recording:
let colors: [Color] = isUrgent
? [palette.recordRed, palette.recordRed.opacity(0.85)]
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
? [recordingTint, recordingTint.opacity(0.85)]
: [recordingTint.opacity(0.95), recordingTint.opacity(0.75)]
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
case .processing:
return LinearGradient(
@@ -213,45 +232,49 @@ private struct RecordButtonPressModifier: ViewModifier {
@Binding var longPressArmed: Bool
let onToggle: () -> Void
let onClipboardLongPressBegan: (() -> Void)?
let onClipboardLongPressEnded: (() -> Void)?
func body(content: Content) -> some View {
if supportsClipboardLongPress {
content.onLongPressGesture(
minimumDuration: ClipboardMaterialFilter.longPressDuration,
maximumDistance: 120,
pressing: { pressing in
if pressing {
longPressArmed = false
return
}
// Released.
if longPressArmed {
onClipboardLongPressEnded?()
longPressArmed = false
} else if phase != .processing, isEnabled || phase == .idleUnavailable {
// Short press existing tap-toggle dictation.
guard phase != .recording else {
// If somehow recording without arming, end via toggle.
onToggle()
return
}
onToggle()
}
},
perform: {
guard phase != .processing else { return }
guard isEnabled || phase == .idleUnavailable else { return }
longPressArmed = true
onClipboardLongPressBegan?()
}
)
} else {
// While recording/processing, only tap-to-toggle never treat finger-up
// from the starting long-press as stop (clipboard is explicitly tap-to-stop).
switch phase {
case .recording, .processing:
content.onTapGesture {
guard phase != .processing else { return }
guard isEnabled || phase == .idleUnavailable else { return }
onToggle()
}
case .idleReady, .idleUnavailable, .error:
if supportsClipboardLongPress {
content.onLongPressGesture(
minimumDuration: ClipboardMaterialFilter.longPressDuration,
maximumDistance: 120,
pressing: { pressing in
if pressing {
longPressArmed = false
return
}
// Released before / without arming short press = dictation toggle.
// Armed release is ignored here; stop happens on a later tap
// once phase becomes `.recording`.
if longPressArmed {
longPressArmed = false
return
}
guard isEnabled || phase == .idleUnavailable else { return }
onToggle()
},
perform: {
guard isEnabled || phase == .idleUnavailable else { return }
longPressArmed = true
onClipboardLongPressBegan?()
}
)
} else {
content.onTapGesture {
guard isEnabled || phase == .idleUnavailable else { return }
onToggle()
}
}
}
}
}
+7 -2
View File
@@ -39,6 +39,8 @@ public struct ThemePalette: Sendable, Equatable {
public let dividerStrong: Color
public let recordRed: Color
/// Clipboard-command hold-to-talk recording (distinct from dictation red).
public let recordBlue: Color
}
public enum Palette {
@@ -70,6 +72,7 @@ public enum Palette {
// Recording state
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
public static let recordBlue = Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
/// Canonical dark palette preserves every legacy literal above so
/// existing call sites that read `Palette.background` directly keep
@@ -92,7 +95,8 @@ public enum Palette {
textOnAccent: textOnAccent,
divider: divider,
dividerStrong: dividerStrong,
recordRed: recordRed
recordRed: recordRed,
recordBlue: recordBlue
)
/// Light palette warm gray backgrounds for daytime use.
@@ -113,7 +117,8 @@ public enum Palette {
textOnAccent: Color.white,
divider: Color.black.opacity(0.06),
dividerStrong: Color.black.opacity(0.10),
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
)
}
@@ -67,6 +67,53 @@ public enum FlowHandoffPolicy {
)
}
/// Proactive keyboard-open PiP arm. Returns false when the host is already
/// ready, warming, busy, or a recent startflow is still in cooldown
/// so Voice-tab / appear noise cannot re-jump to the app.
///
/// `heartbeatStaleness`: seconds since last host heartbeat (`nil` = never).
/// A lingering `sessionActive` after force-quit must still allow arm once the
/// heartbeat is clearly gone without treating brief switcher flaps as death.
public static let proactiveUnreachableArmGrace: TimeInterval = 8
public static func shouldProactivePiPArm(
hostReady: Bool,
snapshotReason: FlowReadySnapshot.Reason?,
sessionActive: Bool,
hostReachable: Bool,
hostStale: Bool,
withinReadyGrace: Bool,
inCooldown: Bool,
heartbeatStaleness: TimeInterval? = nil
) -> Bool {
guard !hostReady, !inCooldown else { return false }
if let snapshotReason {
switch snapshotReason {
case .recording, .processing, .awaitingDelivery, .starting,
.waitingForAudioProof, .audioEngineNotLive:
return false
case .ready, .noSession, .permissionMissing, .appGroupUnavailable,
.hostLost, .error:
break
}
}
// Force-quit leaves sessionActive=true until the 60s zombie window.
// If heartbeat has been gone long enough, arm once (cooldown still applies).
if sessionActive,
!hostReachable,
!hostStale,
let staleness = heartbeatStaleness,
staleness >= proactiveUnreachableArmGrace {
return true
}
return shouldOpenHostColdStart(
sessionActive: sessionActive,
hostReachable: hostReachable,
hostStale: hostStale,
withinReadyGrace: withinReadyGrace
)
}
/// Mic-press routing shared by the keyboard coordinator and unit tests.
public static func micPressAction(
availability: MicVoiceAvailability,
@@ -22,6 +22,7 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
}
}
/// Stable Rime schema `name:` (not UI copy). Keep Chinese so redeploy fingerprints stay stable.
public var displayName: String {
switch self {
case .fullPinyin: return "全拼"
@@ -29,6 +30,15 @@ public enum TypingInputSchema: String, CaseIterable, Identifiable, Codable, Send
case .sogouDoublePinyin: return "搜狗双拼"
}
}
/// Localizable UI label key (`AppL10n` / `SharedL10n`).
public var labelKey: String {
switch self {
case .fullPinyin: return "typing.schema.fullPinyin"
case .microsoftDoublePinyin: return "typing.schema.microsoftDoublePinyin"
case .sogouDoublePinyin: return "typing.schema.sogouDoublePinyin"
}
}
}
public enum PinyinFuzzyPair: String, CaseIterable, Identifiable, Codable, Sendable {
@@ -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 systemalert 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 infor 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 clipboardso 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 = {}