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:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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