feat(keyboard): ship AI hint carousel, home library cards, and clipboard polish
Rotate AI idle suggestions with optional remote packs, move history/dictionary onto self-sizing Home preview cards, harden clipboard capture/prompting, and simplify keyboard chrome by dropping most liquid-glass shadows.
This commit is contained in:
@@ -8,20 +8,51 @@ import Foundation
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
protocol ClipboardPasteboardProviding: AnyObject {
|
||||
var changeCount: Int { get }
|
||||
var hasStrings: Bool { get }
|
||||
var string: String? { get }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SystemClipboardPasteboard: ClipboardPasteboardProviding {
|
||||
var changeCount: Int { UIPasteboard.general.changeCount }
|
||||
var hasStrings: Bool { UIPasteboard.general.hasStrings }
|
||||
var string: String? { UIPasteboard.general.string }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ClipboardCaptureCoordinator {
|
||||
/// Universal Clipboard may synchronously fetch from another device for
|
||||
/// seconds. System pasteboard reads must never block keyboard presentation.
|
||||
private static let readQueue = DispatchQueue(
|
||||
label: "com.osgkeyboard.clipboard.read",
|
||||
qos: .utility
|
||||
)
|
||||
private static let pollInterval: TimeInterval = 0.8
|
||||
|
||||
private let state: KeyboardState
|
||||
private let history: ClipboardHistoryStore
|
||||
private let pasteboard: ClipboardPasteboardProviding
|
||||
private var pollTimer: Timer?
|
||||
private var isSecureProvider: () -> Bool = { false }
|
||||
private var hasFullAccessProvider: () -> Bool = { false }
|
||||
/// Ephemeral only: leaving a secure field must not resurrect old body text.
|
||||
private var secureFieldSuppressedChangeCount: Int?
|
||||
private var isSecureEntryActive = false
|
||||
private var isSampling = false
|
||||
private var forcesNextSample = true
|
||||
private var isKeyboardVisible = false
|
||||
|
||||
init(
|
||||
state: KeyboardState,
|
||||
history: ClipboardHistoryStore = .shared
|
||||
history: ClipboardHistoryStore = .shared,
|
||||
pasteboard: ClipboardPasteboardProviding = SystemClipboardPasteboard()
|
||||
) {
|
||||
self.state = state
|
||||
self.history = history
|
||||
self.pasteboard = pasteboard
|
||||
}
|
||||
|
||||
func configure(
|
||||
@@ -33,22 +64,54 @@ final class ClipboardCaptureCoordinator {
|
||||
}
|
||||
|
||||
func keyboardDidAppear() {
|
||||
isKeyboardVisible = true
|
||||
history.reload()
|
||||
refreshSuggestionFromStore()
|
||||
captureIfNeeded(force: true)
|
||||
// A suggestion belongs to one keyboard presentation. Clear any
|
||||
// presentation state left behind by a reused extension controller.
|
||||
endCurrentSuggestion()
|
||||
forcesNextSample = true
|
||||
// Delay the system pasteboard read until the first poll tick. A
|
||||
// Universal Clipboard fetch or paste alert during the appear sequence
|
||||
// can otherwise freeze the keyboard before SwiftUI draws.
|
||||
if !(pasteboard is SystemClipboardPasteboard) {
|
||||
captureIfNeeded(forceRead: true)
|
||||
}
|
||||
startPolling()
|
||||
}
|
||||
|
||||
func keyboardWillDisappear() {
|
||||
isKeyboardVisible = false
|
||||
stopPolling()
|
||||
// A1 policy: closing the keyboard ends this generation's suggestion.
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
|
||||
func refreshFlagsFromStore() {
|
||||
// Called from App Group poll — suggestion visibility may change.
|
||||
refreshSuggestionFromStore()
|
||||
// Settings changes may hide the active suggestion, but enabling the
|
||||
// strip must wait for a new pasteboard generation.
|
||||
if !state.clipboardHistoryEnabled || !state.clipboardCandidateBarEnabled {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
func secureEntryDidChange(isSecure: Bool) {
|
||||
if isSecure {
|
||||
isSecureEntryActive = true
|
||||
secureFieldSuppressedChangeCount = pasteboard.changeCount
|
||||
} else if isSecureEntryActive {
|
||||
// Capture the latest generation once more on exit so a pasteboard
|
||||
// change near the secure-field transition cannot be persisted.
|
||||
secureFieldSuppressedChangeCount = pasteboard.changeCount
|
||||
isSecureEntryActive = false
|
||||
} else {
|
||||
return
|
||||
}
|
||||
endCurrentSuggestion()
|
||||
state.clipboardOverlay = .none
|
||||
}
|
||||
|
||||
func openPanelFromTopButton() {
|
||||
guard state.canShowClipboardEntry else { return }
|
||||
if state.clipboardHistoryEnabled {
|
||||
history.reload()
|
||||
state.clipboardOverlay = .historyPanel
|
||||
@@ -62,15 +125,15 @@ final class ClipboardCaptureCoordinator {
|
||||
}
|
||||
|
||||
func noteUserDidInputText() {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
|
||||
func dismissSuggestion() {
|
||||
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
|
||||
clearSuggestion(persistDismiss: true)
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
|
||||
func insertText(_ text: String, via insert: (String) -> Void) {
|
||||
guard state.canShowClipboardEntry else { return }
|
||||
insert(text)
|
||||
// Tapping a suggestion (or history row that shares this path) must not
|
||||
// resurface the same clipboard changeCount until the pasteboard changes.
|
||||
@@ -79,24 +142,28 @@ final class ClipboardCaptureCoordinator {
|
||||
}
|
||||
|
||||
func clearHistory() {
|
||||
endCurrentSuggestion()
|
||||
history.clearAll()
|
||||
clearSuggestion(persistDismiss: false)
|
||||
}
|
||||
|
||||
func deleteEntry(id: UUID) {
|
||||
let deletedChangeCount = history.entries.first(where: { $0.id == id })?.changeCount
|
||||
history.remove(id: id)
|
||||
refreshSuggestionFromStore()
|
||||
if deletedChangeCount == state.clipboardSuggestionChangeCount {
|
||||
endCurrentSuggestion()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Capture
|
||||
|
||||
private func startPolling() {
|
||||
stopPolling()
|
||||
let timer = Timer(timeInterval: 0.8, repeats: true) { [weak self] _ in
|
||||
let timer = Timer(timeInterval: Self.pollInterval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.captureIfNeeded(force: false)
|
||||
self?.captureIfNeeded()
|
||||
}
|
||||
}
|
||||
timer.tolerance = Self.pollInterval / 4
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
pollTimer = timer
|
||||
}
|
||||
@@ -106,83 +173,170 @@ final class ClipboardCaptureCoordinator {
|
||||
pollTimer = nil
|
||||
}
|
||||
|
||||
private func captureIfNeeded(force: Bool) {
|
||||
func captureIfNeeded(forceRead: Bool = false) {
|
||||
guard state.clipboardHistoryEnabled else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
endCurrentSuggestion()
|
||||
return
|
||||
}
|
||||
#if DEBUG
|
||||
// What's New demo seeds history itself — never touch the pasteboard
|
||||
// (avoids the simulator “允许粘贴” alert mid-recording).
|
||||
if WhatsNewDemoScenario.peek() != nil || WhatsNewDemoScenario.isPlaying() {
|
||||
return
|
||||
}
|
||||
#endif
|
||||
guard hasFullAccessProvider() else { return }
|
||||
guard !isSecureProvider() else { return }
|
||||
|
||||
let pasteboard = UIPasteboard.general
|
||||
let changeCount = pasteboard.changeCount
|
||||
if !force, changeCount == history.lastObservedChangeCount {
|
||||
refreshSuggestionFromStore()
|
||||
guard !isSecureProvider() else {
|
||||
secureEntryDidChange(isSecure: true)
|
||||
return
|
||||
}
|
||||
|
||||
if pasteboard is SystemClipboardPasteboard {
|
||||
beginSystemSample(forceRead: forceRead || forcesNextSample)
|
||||
forcesNextSample = false
|
||||
return
|
||||
}
|
||||
captureInjectedPasteboard(forceRead: forceRead)
|
||||
}
|
||||
|
||||
/// Synchronous path retained for deterministic tests and injected fakes.
|
||||
/// Production always uses `beginSystemSample` below.
|
||||
private func captureInjectedPasteboard(forceRead: Bool) {
|
||||
let changeCount = pasteboard.changeCount
|
||||
if ClipboardHistoryPolicy.shouldSuppressCapture(
|
||||
changeCount: changeCount,
|
||||
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
|
||||
) {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
clearSuggestion()
|
||||
return
|
||||
}
|
||||
secureFieldSuppressedChangeCount = nil
|
||||
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
|
||||
if isCurrentGeneration && !forceRead {
|
||||
return
|
||||
}
|
||||
|
||||
// A new generation replaces any previous transient suggestion,
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
refreshSuggestionFromStore()
|
||||
return
|
||||
}
|
||||
|
||||
let raw = pasteboard.string
|
||||
// The forced appearance read exists only to establish/refresh iOS
|
||||
// paste permission. It must not reinsert or republish old content.
|
||||
if isCurrentGeneration {
|
||||
return
|
||||
}
|
||||
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
refreshSuggestionFromStore()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSuggestionFromStore() {
|
||||
guard state.clipboardHistoryEnabled,
|
||||
state.clipboardCandidateBarEnabled,
|
||||
!isSecureProvider(),
|
||||
let newest = history.newestEntry
|
||||
else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
private struct Sample: Sendable {
|
||||
let changeCount: Int
|
||||
let hasStrings: Bool
|
||||
let text: String?
|
||||
}
|
||||
|
||||
private func beginSystemSample(forceRead: Bool) {
|
||||
guard !isSampling else { return }
|
||||
isSampling = true
|
||||
let lastObserved = history.lastObservedChangeCount
|
||||
|
||||
Self.readQueue.async {
|
||||
let pasteboard = UIPasteboard.general
|
||||
let changeCount = pasteboard.changeCount
|
||||
guard forceRead || changeCount != lastObserved else {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isSampling = false
|
||||
}
|
||||
return
|
||||
}
|
||||
let hasStrings = pasteboard.hasStrings
|
||||
let sample = Sample(
|
||||
changeCount: changeCount,
|
||||
hasStrings: hasStrings,
|
||||
text: hasStrings ? pasteboard.string : nil
|
||||
)
|
||||
Task { @MainActor [weak self] in
|
||||
self?.finishSystemSample(sample)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishSystemSample(_ sample: Sample) {
|
||||
isSampling = false
|
||||
guard isKeyboardVisible,
|
||||
state.clipboardHistoryEnabled,
|
||||
hasFullAccessProvider(),
|
||||
!isSecureProvider()
|
||||
else { return }
|
||||
|
||||
let changeCount = sample.changeCount
|
||||
if ClipboardHistoryPolicy.shouldSuppressCapture(
|
||||
changeCount: changeCount,
|
||||
secureFieldSuppressedChangeCount: secureFieldSuppressedChangeCount
|
||||
) {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
clearSuggestion()
|
||||
return
|
||||
}
|
||||
let changeCount = newest.changeCount ?? history.lastObservedChangeCount
|
||||
guard history.shouldShowSuggestion(
|
||||
forChangeCount: changeCount,
|
||||
candidateBarEnabled: state.clipboardCandidateBarEnabled,
|
||||
historyEnabled: state.clipboardHistoryEnabled
|
||||
) else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
secureFieldSuppressedChangeCount = nil
|
||||
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
|
||||
|
||||
// A new generation replaces any previous transient suggestion,
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
guard sample.hasStrings else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
}
|
||||
// Don't resurrect a strip the user already dismissed this session
|
||||
// unless changeCount advanced (handled in ingest).
|
||||
if state.clipboardSuggestionText == nil,
|
||||
let dismissed = history.suggestionDismissedChangeCount,
|
||||
dismissed == changeCount {
|
||||
return
|
||||
// Forced appearance reads establish iOS paste permission only. Never
|
||||
// republish content from an already observed generation.
|
||||
guard !isCurrentGeneration else { return }
|
||||
|
||||
if let entry = history.ingest(rawText: sample.text, changeCount: changeCount) {
|
||||
updateSuggestion(with: entry, changeCount: changeCount)
|
||||
} else {
|
||||
history.lastObservedChangeCount = changeCount
|
||||
}
|
||||
state.clipboardSuggestionText = newest.text
|
||||
state.clipboardSuggestionChangeCount = changeCount
|
||||
}
|
||||
|
||||
private func updateSuggestion(with entry: ClipboardHistoryEntry, changeCount: Int) {
|
||||
guard state.clipboardCandidateBarEnabled else {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
guard state.canShowClipboardEntry,
|
||||
state.clipboardCandidateBarEnabled,
|
||||
changeCount != secureFieldSuppressedChangeCount
|
||||
else {
|
||||
clearSuggestion()
|
||||
return
|
||||
}
|
||||
// Already used/dismissed this pasteboard generation — keep it hidden.
|
||||
if history.suggestionDismissedChangeCount == changeCount {
|
||||
clearSuggestion(persistDismiss: false)
|
||||
clearSuggestion()
|
||||
return
|
||||
}
|
||||
state.clipboardSuggestionText = entry.text
|
||||
state.clipboardSuggestionChangeCount = changeCount
|
||||
}
|
||||
|
||||
private func clearSuggestion(persistDismiss: Bool) {
|
||||
if persistDismiss {
|
||||
// already written in dismissSuggestion
|
||||
private func endCurrentSuggestion() {
|
||||
history.dismissSuggestion(forChangeCount: state.clipboardSuggestionChangeCount)
|
||||
clearSuggestion()
|
||||
}
|
||||
|
||||
private func clearSuggestion() {
|
||||
guard state.clipboardSuggestionText != nil
|
||||
|| state.clipboardSuggestionChangeCount != nil
|
||||
else {
|
||||
return
|
||||
}
|
||||
state.clipboardSuggestionText = nil
|
||||
state.clipboardSuggestionChangeCount = nil
|
||||
|
||||
Reference in New Issue
Block a user