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:
@@ -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
|
||||
// system「允许粘贴」alert 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
@@ -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 {
|
||||
|
||||
@@ -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 isn’t usable";
|
||||
"keyboard.clipboard.reject.secureField" = "Clipboard commands aren’t available in password fields";
|
||||
"keyboard.clipboard.reject.noFullAccess" = "Full Access is required for clipboard commands";
|
||||
"keyboard.clipboard.reject.prepareFailed" = "Mic wasn’t 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" = "本地";
|
||||
|
||||
Reference in New Issue
Block a user