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
+93 -16
View File
@@ -50,6 +50,16 @@ public final class KeyboardViewController: UIInputViewController {
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint?
private var systemEncapsulatedHeight: CGFloat = 228
/// Presentation height priming is only valid during the slide-in. After
/// `viewDidAppear` we must keep the constraint at `target` re-applying
/// the offset (or letting a paste alert interrupt the appear sequence)
/// makes the slot land at `target + encapsulated` and floats the chrome.
private enum HeightPresentationPhase {
case idle
case priming
case presented
}
private var heightPhase: HeightPresentationPhase = .idle
private var cancellables = Set<AnyCancellable>()
private var textInserter: KeyboardTextInserter!
@@ -118,6 +128,7 @@ public final class KeyboardViewController: UIInputViewController {
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
flowCoordinator.restoreClipboardCommandIfNeeded()
OSGDiag.log(
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
@@ -133,15 +144,27 @@ public final class KeyboardViewController: UIInputViewController {
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
category: "boot"
)
heightPhase = .idle
// Block pasteboard content reads until the next viewDidAppear height lock.
flowCoordinator.setClipboardContentReadsEnabled(false)
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
TypingInputConfiguration.persistLastSurface(state.surface)
prepareSurfaceForNextPresentation()
// Skip snap-to-typing while clipboard paste alert / utterance owns the mic
// otherwise Allow Paste reopens on the typing grid (visible jump).
let preserve = flowCoordinator.preservesLifecycleOnDisappear
|| flowCoordinator.isClipboardCommandActive
|| ClipboardCommandResume.shouldPreferVoice()
TypingInputConfiguration.persistLastSurface(
preserve || ClipboardCommandResume.shouldPreferVoice() ? .voice : state.surface
)
if !preserve {
prepareSurfaceForNextPresentation()
}
if state.surface == .typing {
typingSession.leaveTypingMode()
}
if flowCoordinator.preservesLifecycleOnDisappear {
if preserve {
return
}
ExtensionScreenWakeLock.releaseAll()
@@ -159,13 +182,16 @@ public final class KeyboardViewController: UIInputViewController {
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
state.debugHasFullAccess = hasFullAccess
// Do NOT read pasteboard contents here `refreshSessionState` may peek
// changeCount only while content reads stay disabled until height locks.
flowCoordinator.refreshSessionState()
flowCoordinator.refreshClipboardEligibility()
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
// After paste-alert reopen: restore clipboard chrome if sticky + host busy.
flowCoordinator.restoreClipboardCommandIfNeeded()
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
// skips when the extension process is reused, leaving generators cold.
KeyboardHapticFeedback.prepare()
@@ -181,9 +207,17 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
if heightPhase == .presented {
// Spurious re-appear while already on screen (e.g. system alert
// lifecycle noise) never re-run the offset trick.
lockPresentedKeyboardHeight()
} else {
heightPhase = .priming
applyPresentationHeightOffset()
}
OSGDiag.log(
"KVC.viewIsAppearing height=\(keyboardHeightConstraint?.constant ?? -1) "
"KVC.viewIsAppearing phase=\(heightPhaseLog) "
+ "height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
@@ -196,12 +230,15 @@ public final class KeyboardViewController: UIInputViewController {
category: "boot"
)
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
heightPhase = .presented
lockPresentedKeyboardHeight()
refreshReturnKeyRole()
// Run after the extension is fully presented so UIKit accepts the
// containing-app handoff even when typing mode is the default surface.
// After height is locked: (1) allow pasteboard content reads so the
// paste alert cannot interrupt presentation math; (2) arm PiP handoff.
DispatchQueue.main.async { [weak self] in
self?.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
guard let self, self.heightPhase == .presented else { return }
self.flowCoordinator.setClipboardContentReadsEnabled(true)
self.flowCoordinator.ensurePiPReadyOnKeyboardOpen()
}
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
@@ -240,6 +277,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cursorDrag?.layoutChrome()
enforcePresentedKeyboardHeightIfNeeded()
}
// MARK: - Services
@@ -290,9 +328,6 @@ public final class KeyboardViewController: UIInputViewController {
state.beginClipboardCommand = { [weak self] in
self?.flowCoordinator.clipboardCommandPressBegan()
}
state.endClipboardCommand = { [weak self] in
self?.flowCoordinator.clipboardCommandPressEnded()
}
state.refreshClipboardEligibility = { [weak self] in
self?.flowCoordinator.refreshClipboardEligibility()
}
@@ -368,13 +403,21 @@ public final class KeyboardViewController: UIInputViewController {
private func applyPreferredSurfaceOnOpen() {
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
let sticky = ClipboardCommandResume.shouldPreferVoice()
let resolved = KeyboardOpenSurfacePolicy.resolve(
locksTypingSurface: state.locksTypingSurface,
clipboardCommandActive: flowCoordinator.isClipboardCommandActive,
stickyPreferVoice: sticky,
preferred: preferred
)
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
+ "remember=\(TypingInputConfiguration.remembersLastSurface()) "
+ "defaultTyping=\(TypingInputConfiguration.prefersTypingOnOpen())",
+ "resolved=\(resolved.rawValue) stickyVoice=\(sticky ? 1 : 0) "
+ "clipboardActive=\(flowCoordinator.isClipboardCommandActive ? 1 : 0) "
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
category: "boot"
)
applySurface(preferred)
applySurface(resolved)
}
/// When not remembering, snap to the static open preference while hidden
@@ -395,10 +438,42 @@ public final class KeyboardViewController: UIInputViewController {
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
if heightPhase == .presented {
lockPresentedKeyboardHeight()
} else {
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
}
}
private var heightPhaseLog: String {
switch heightPhase {
case .idle: return "idle"
case .priming: return "priming"
case .presented: return "presented"
}
}
private func lockPresentedKeyboardHeight() {
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
}
/// After presentation, the constraint must stay at `target`. Spurious
/// lifecycle noise must not leave us primed at `target encapsulated`.
private func enforcePresentedKeyboardHeightIfNeeded() {
guard heightPhase == .presented else { return }
let target = targetKeyboardHeight
guard let constraint = keyboardHeightConstraint else { return }
let was = constraint.constant
guard abs(was - target) > 0.5 else { return }
constraint.constant = target
OSGDiag.log(
"KVC.heightEnforce constraint \(Int(was))\(Int(target))",
category: "boot"
)
}
private func refreshReturnKeyRole() {
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
// Secure fields must not run English autocomplete / autocorrect / learning.
@@ -480,6 +555,8 @@ public final class KeyboardViewController: UIInputViewController {
}
private func applyPresentationHeightOffset() {
// Only valid while priming the slide-in. Callers must not invoke this
// after `heightPhase == .presented`.
if let encapsulated = view.constraints.first(where: { constraint in
constraint.firstItem as? UIView === view
&& constraint.firstAttribute == .height
@@ -1,12 +1,27 @@
// ClipboardPasteboardReader.swift
// OSGKeyboard · Keyboard Extension
//
// Opportunity-read of UIPasteboard for clipboard-command eligibility.
// Pasteboard peeks for clipboard-command UI and long-press snapshot capture.
//
// Idle affordance must use metadata only (`hasStrings` / `changeCount`) so the
// systemalert never appears while the keyboard is merely open.
// Content reads (`string`) happen only on an explicit long-press.
import UIKit
import OSGKeyboardShared
enum ClipboardPasteboardReader {
/// Metadata-only does not trigger the paste permission prompt.
static func changeCount() -> Int {
UIPasteboard.general.changeCount
}
/// Metadata-only whether the pasteboard currently holds string items.
static func hasStrings() -> Bool {
UIPasteboard.general.hasStrings
}
/// Content sample for long-press snapshot. May present the system paste alert.
static func sample() -> (changeCount: Int, text: String?) {
let board = UIPasteboard.general
let changeCount = board.changeCount
File diff suppressed because it is too large Load Diff
+124 -50
View File
@@ -132,7 +132,8 @@ public struct KeyboardRootView: View {
transcript: state.lastTranscript,
micVoiceAvailability: state.micVoiceAvailability,
micDisabledHint: state.micDisabledHint,
clipboardCommandEligible: state.clipboardCommandEligible || state.clipboardCommandSessionActive,
clipboardCommandEligible: state.clipboardCommandEligible,
clipboardFailureHint: state.clipboardFailureHint,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings
)
@@ -177,18 +178,24 @@ public struct KeyboardRootView: View {
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
HStack(spacing: 0) {
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingLeft"),
visible: state.clipboardCommandRecording && !dragging
)
}
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: !state.micDisabled,
isEnabled: micButtonEnabled,
isClipboardCommandRecording: state.clipboardCommandRecording,
onToggle: state.tapMic,
onClipboardLongPressBegan: (state.clipboardCommandEligible || state.clipboardCommandSessionActive)
onClipboardLongPressBegan: (state.clipboardCommandEligible
|| state.clipboardCommandUtteranceActive)
&& micButtonEnabled
? state.beginClipboardCommand
: nil,
onClipboardLongPressEnded: (state.clipboardCommandEligible || state.clipboardCommandSessionActive)
? state.endClipboardCommand
: nil
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
@@ -196,8 +203,15 @@ public struct KeyboardRootView: View {
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
.overlay {
clipboardSideHint(
ExtL10n.text("keyboard.clipboard.recordingRight"),
visible: state.clipboardCommandRecording && !dragging
)
}
}
.frame(height: KeyboardLayoutMetrics.micSize)
.animation(.easeInOut(duration: 0.25), value: state.clipboardCommandRecording)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
@@ -240,6 +254,23 @@ public struct KeyboardRootView: View {
.contentShape(Rectangle())
}
/// Side caption beside the mic during clipboard-command recording.
/// Vertically matches the mic disc (same upward offset); does not steal touches.
private func clipboardSideHint(_ text: Text, visible: Bool) -> some View {
text
// 22pt ~18pt (20%); softer than body so it doesn't compete with the mic.
.font(.system(size: 17.6, weight: .medium))
.foregroundStyle(palette.textSecondary.opacity(0.42))
.multilineTextAlignment(.center)
.lineLimit(3)
.minimumScaleFactor(0.7)
.padding(.horizontal, 2)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(visible ? 1 : 0)
.allowsHitTesting(false)
.accessibilityHidden(!visible)
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(
disabled: disabled,
@@ -287,6 +318,14 @@ public struct KeyboardRootView: View {
}
private var buttonPhase: RecordButton.Phase {
switch clipboardMicChrome {
case .preparingDisabled:
return .processing
case .recordingBlue:
return .recording
case .none:
break
}
switch state.micVoiceAvailability {
case .recording:
return .recording
@@ -298,6 +337,32 @@ public struct KeyboardRootView: View {
return .idleReady
}
}
/// Preparing clipboard capture: grey spinner, not tappable.
private var micButtonEnabled: Bool {
if state.micDisabled { return false }
if clipboardMicChrome == .preparingDisabled { return false }
return true
}
private var clipboardMicChrome: ClipboardMicChrome {
let phase: ClipboardPreparingPhase = {
switch state.phase {
case .idle: return .idle
case .denied: return .denied
case .error: return .error
case .requestingPermissions: return .requestingPermissions
case .recording: return .recording
case .processing: return .processing
}
}()
return ClipboardPreparingPolicy.micChrome(
isClipboardUtterance: state.clipboardCommandUtteranceActive,
phase: phase,
awaitingHostConfirm: state.phase == .requestingPermissions
|| (state.phase == .recording && !state.clipboardCommandRecording)
)
}
}
// MARK: - State alias
@@ -347,6 +412,7 @@ private struct TranscriptLine: View {
let micVoiceAvailability: MicVoiceAvailability
let micDisabledHint: String
let clipboardCommandEligible: Bool
let clipboardFailureHint: String?
let cursorDragHintActive: Bool
let openSettings: () -> Void
@@ -419,52 +485,60 @@ private struct TranscriptLine: View {
@ViewBuilder
private var idleHint: some View {
let isWarning: Bool = {
switch micVoiceAvailability {
case .unavailable(.hostNotReady), .unavailable(.preparingSession):
return false
case .unavailable:
return true
case .ready, .recording, .processing:
return false
}
}()
Group {
switch micVoiceAvailability {
case .ready:
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.preparingSession):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .unavailable(.onboardingIncomplete):
ExtL10n.text("keyboard.hint.finishSetupInApp")
case .recording, .processing:
EmptyView()
if let clipboardFailureHint, !clipboardFailureHint.isEmpty {
Text(clipboardFailureHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else {
let isWarning: Bool = {
switch micVoiceAvailability {
case .unavailable(.hostNotReady), .unavailable(.preparingSession):
return false
case .unavailable:
return true
case .ready, .recording, .processing:
return false
}
}()
Group {
switch micVoiceAvailability {
case .ready:
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.missingAPIKey):
Text(micDisabledHint)
case .unavailable(.hostNotReady):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.preparingSession):
if clipboardCommandEligible {
ExtL10n.text("keyboard.placeholder.idleClipboard")
} else {
ExtL10n.text("keyboard.placeholder.idle")
}
case .unavailable(.noFullAccess):
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .unavailable(.onboardingIncomplete):
ExtL10n.text("keyboard.hint.finishSetupInApp")
case .recording, .processing:
EmptyView()
}
}
.font(TypeStyle.caption)
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
.lineLimit(1)
.truncationMode(.tail)
}
.font(TypeStyle.caption)
.foregroundStyle(isWarning ? palette.warning : palette.textTertiary)
.lineLimit(1)
.truncationMode(.tail)
}
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
+13
View File
@@ -116,8 +116,21 @@
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "Tap to talk";
"keyboard.placeholder.idleClipboard" = "Tap to talk, long-press for clipboard";
"keyboard.clipboard.recordingLeft" = "Recording command";
"keyboard.clipboard.recordingRight" = "Tap to finish";
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.preparingRecording" = "Preparing mic…";
"keyboard.clipboard.reject.pasteDenied" = "Allow Paste to process the clipboard";
"keyboard.clipboard.reject.empty" = "No text on the clipboard to process";
"keyboard.clipboard.reject.phoneOrNumeric" = "Looks like a phone number — not started";
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "No usable text on the clipboard";
"keyboard.clipboard.reject.verificationCode" = "Looks like a code — not started";
"keyboard.clipboard.reject.tooShort" = "Clipboard text is too short";
"keyboard.clipboard.reject.repetitiveSpam" = "Clipboard text isnt usable";
"keyboard.clipboard.reject.secureField" = "Clipboard commands arent available in password fields";
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
"keyboard.clipboard.reject.prepareFailed" = "Mic wasnt ready in time — try again";
"keyboard.clipboard.hint.hostStarting" = "Starting… return and long-press the mic again";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
"keyboard.placeholder.localBadge" = "On-device";
@@ -116,8 +116,21 @@
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "点按说话";
"keyboard.placeholder.idleClipboard" = "点击说话,长按处理剪贴板";
"keyboard.clipboard.recordingLeft" = "指令录制中";
"keyboard.clipboard.recordingRight" = "点按结束处理";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.preparingRecording" = "准备录音…";
"keyboard.clipboard.reject.pasteDenied" = "需要允许粘贴才能处理剪贴板";
"keyboard.clipboard.reject.empty" = "剪贴板里没有可处理的文字";
"keyboard.clipboard.reject.phoneOrNumeric" = "看起来像号码,未开始处理";
"keyboard.clipboard.reject.emojiOrSymbolOnly" = "没有可处理的文字内容";
"keyboard.clipboard.reject.verificationCode" = "看起来像验证码,未开始处理";
"keyboard.clipboard.reject.tooShort" = "内容太短,请复制更完整的文字";
"keyboard.clipboard.reject.repetitiveSpam" = "内容无效,未开始处理";
"keyboard.clipboard.reject.secureField" = "密码框中不能使用剪贴板指令";
"keyboard.clipboard.reject.noFullAccess" = "需要开启完全访问才能处理剪贴板";
"keyboard.clipboard.reject.prepareFailed" = "麦克风未能及时就绪,请再试一次";
"keyboard.clipboard.hint.hostStarting" = "正在启动,返回后请再长按处理剪贴板";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
"keyboard.placeholder.localBadge" = "本地";