feat(keyboard): unify assistant voice and AI workflows
Merge dictation and AI controls into one assistant surface, preserve safe insertion and clipboard actions, and align settings and tests with the new flow.
This commit is contained in:
@@ -39,6 +39,8 @@ struct OSGKeyboardApp: App {
|
||||
AIKeyboardDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
||||
AIClipboardSkillLayoutDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
|
||||
AssistantKeyboardUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||
ClipboardHistoryDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
||||
|
||||
@@ -22,6 +22,14 @@ enum AppPermissions {
|
||||
case restricted
|
||||
}
|
||||
|
||||
enum PasteAccessResult: Equatable {
|
||||
case verified
|
||||
case noTextAvailable
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private static let pasteAccessVerifiedKey = "clipboard.pasteAccessVerified.v1"
|
||||
|
||||
static var micStatus: MicStatus {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: return .granted
|
||||
@@ -46,6 +54,12 @@ enum AppPermissions {
|
||||
micStatus == .granted && speechStatus == .granted
|
||||
}
|
||||
|
||||
/// iOS does not expose the current "Paste from Other Apps" setting.
|
||||
/// This records the last explicit, successful user-initiated read instead.
|
||||
static var hasVerifiedPasteAccess: Bool {
|
||||
UserDefaults.standard.bool(forKey: pasteAccessVerifiedKey)
|
||||
}
|
||||
|
||||
/// Show guided permission pages when any Flow permission is not granted.
|
||||
static var needsPermissionGuidance: Bool {
|
||||
micStatus != .granted || speechStatus != .granted
|
||||
@@ -82,10 +96,15 @@ enum AppPermissions {
|
||||
/// and create the app's "Paste from Other Apps" settings entry.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
static func requestPasteAccess() -> Bool {
|
||||
static func requestPasteAccess() -> PasteAccessResult {
|
||||
let pasteboard = UIPasteboard.general
|
||||
guard pasteboard.hasStrings else { return false }
|
||||
return pasteboard.string != nil
|
||||
guard pasteboard.hasStrings else { return .noTextAvailable }
|
||||
guard pasteboard.string != nil else {
|
||||
UserDefaults.standard.set(false, forKey: pasteAccessVerifiedKey)
|
||||
return .unavailable
|
||||
}
|
||||
UserDefaults.standard.set(true, forKey: pasteAccessVerifiedKey)
|
||||
return .verified
|
||||
}
|
||||
|
||||
/// Home-screen guidance when Flow permissions are missing after onboarding.
|
||||
|
||||
@@ -12,13 +12,17 @@ import OSGKeyboardShared
|
||||
|
||||
struct AIAgentSkillsView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
||||
|
||||
@State private var viewingSkill: AIClipboardSkill?
|
||||
@State private var editingDraft: SkillEditorDraft?
|
||||
@State private var showFullAlert = false
|
||||
@State private var showClipboardSettings = false
|
||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@State private var showPasteAccessSuccess = false
|
||||
/// True while a skill card is lifted; locks the page scroll like SpringBoard.
|
||||
@State private var isReordering = false
|
||||
|
||||
@@ -35,8 +39,9 @@ struct AIAgentSkillsView: View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: Spacing.xl) {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
clipboardHistoryBanner
|
||||
if showsClipboardAccessGuide {
|
||||
clipboardAccessGuide
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
enabledSection
|
||||
if !store.availableSkills.isEmpty {
|
||||
@@ -59,9 +64,6 @@ struct AIAgentSkillsView: View {
|
||||
.accessibilityLabel(Text("skills.add"))
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $showClipboardSettings) {
|
||||
ClipboardSettingsView(config: config)
|
||||
}
|
||||
}
|
||||
.sheet(item: $viewingSkill) { skill in
|
||||
SkillDetailSheet(
|
||||
@@ -90,53 +92,62 @@ struct AIAgentSkillsView: View {
|
||||
} message: {
|
||||
Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
|
||||
}
|
||||
.onAppear { store.reload() }
|
||||
.alert(
|
||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||
isPresented: $showPasteNoTextAlert
|
||||
) {
|
||||
Button("common.done") { showPasteNoTextAlert = false }
|
||||
} message: {
|
||||
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||
}
|
||||
.onAppear {
|
||||
store.reload()
|
||||
refreshPasteAccessState()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPasteAccessState()
|
||||
pasteAccessNeedsRecovery = false
|
||||
}
|
||||
}
|
||||
|
||||
private var clipboardHistoryBanner: some View {
|
||||
private var clipboardAccessGuide: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Label {
|
||||
Text("skills.clipboard.guide.title")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
} icon: {
|
||||
Image(systemName: "clipboard")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
Text("skills.clipboard.guide.body")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
Image(systemName: clipboardGuideIcon)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(clipboardGuideTint)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(clipboardGuideTint.opacity(0.12), in: Circle())
|
||||
|
||||
Button {
|
||||
showClipboardSettings = true
|
||||
} label: {
|
||||
guideRow(
|
||||
titleKey: "skills.clipboard.guide.openAppSettings",
|
||||
systemImage: "slider.horizontal.3",
|
||||
trailing: "chevron.right"
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(clipboardGuideTitle)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(clipboardGuideBody)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
AppPermissions.openSystemSettings()
|
||||
} label: {
|
||||
guideRow(
|
||||
titleKey: "skills.clipboard.guide.openSystemSettings",
|
||||
systemImage: "gearshape",
|
||||
trailing: "arrow.up.right"
|
||||
)
|
||||
if !showPasteAccessSuccess {
|
||||
Button(action: performClipboardGuideAction) {
|
||||
guideRow(
|
||||
titleKey: clipboardGuideActionTitle,
|
||||
systemImage: clipboardGuideActionIcon,
|
||||
trailing: clipboardGuideActionTrailing
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier(clipboardGuideActionIdentifier)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Spacing.md)
|
||||
.background(palette.surface, in: skillCardShape)
|
||||
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("skills.clipboard.guide")
|
||||
}
|
||||
|
||||
private func guideRow(
|
||||
@@ -157,10 +168,129 @@ struct AIAgentSkillsView: View {
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 44)
|
||||
.background(
|
||||
palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
|
||||
private var showsClipboardAccessGuide: Bool {
|
||||
!config.clipboardHistoryEnabled || !pasteAccessVerified || showPasteAccessSuccess
|
||||
}
|
||||
|
||||
private var clipboardGuideTitle: LocalizedStringKey {
|
||||
if showPasteAccessSuccess {
|
||||
return "skills.clipboard.guide.success.title"
|
||||
}
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.title"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
return "skills.clipboard.guide.recovery.title"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.title"
|
||||
}
|
||||
|
||||
private var clipboardGuideBody: LocalizedStringKey {
|
||||
if showPasteAccessSuccess {
|
||||
return "skills.clipboard.guide.success.body"
|
||||
}
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.body"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
return "skills.clipboard.guide.recovery.body"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.body"
|
||||
}
|
||||
|
||||
private var clipboardGuideIcon: String {
|
||||
if showPasteAccessSuccess {
|
||||
return "checkmark"
|
||||
}
|
||||
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
|
||||
}
|
||||
|
||||
private var clipboardGuideTint: Color {
|
||||
pasteAccessNeedsRecovery ? palette.warning : palette.accent
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTitle: LocalizedStringKey {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
return "skills.clipboard.guide.openSystemSettings"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.action"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionIcon: String {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "clock.arrow.circlepath"
|
||||
}
|
||||
return pasteAccessNeedsRecovery ? "gearshape" : "checkmark.shield"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTrailing: String {
|
||||
pasteAccessNeedsRecovery ? "arrow.up.right" : "arrow.right"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionIdentifier: String {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
return "skills.clipboard.guide.openSystemSettings"
|
||||
}
|
||||
return "skills.clipboard.guide.verifyPaste"
|
||||
}
|
||||
|
||||
private func performClipboardGuideAction() {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
withAnimation(Motion.soft) {
|
||||
config.clipboardHistoryEnabled = true
|
||||
}
|
||||
return
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
AppPermissions.openSystemSettings()
|
||||
return
|
||||
}
|
||||
verifyPasteAccess()
|
||||
}
|
||||
|
||||
private func verifyPasteAccess() {
|
||||
switch AppPermissions.requestPasteAccess() {
|
||||
case .verified:
|
||||
withAnimation(Motion.soft) {
|
||||
pasteAccessVerified = true
|
||||
pasteAccessNeedsRecovery = false
|
||||
showPasteAccessSuccess = true
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
withAnimation(Motion.soft) {
|
||||
showPasteAccessSuccess = false
|
||||
}
|
||||
}
|
||||
case .noTextAvailable:
|
||||
showPasteNoTextAlert = true
|
||||
case .unavailable:
|
||||
withAnimation(Motion.soft) {
|
||||
pasteAccessVerified = false
|
||||
pasteAccessNeedsRecovery = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPasteAccessState() {
|
||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
}
|
||||
|
||||
private var enabledSection: some View {
|
||||
CardSection(
|
||||
title: AppL10n.format(
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
/// Deterministic host for simulator UI tests of the real assistant keyboard.
|
||||
/// It exercises gesture routing and closed UI states without requiring a
|
||||
/// keyboard-extension process, microphone permission, ASR, or an LLM.
|
||||
struct AssistantKeyboardUITestHarness: View {
|
||||
private enum Scenario: String {
|
||||
case idle
|
||||
case completed
|
||||
case pending
|
||||
case skillFailure
|
||||
case skills
|
||||
}
|
||||
|
||||
@StateObject private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var configured = false
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
init() {
|
||||
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||
AIKeyboardView.debugKeepsSkillTip = true
|
||||
}
|
||||
|
||||
private let scenario: Scenario = {
|
||||
let prefix = "--assistant-state="
|
||||
let raw = ProcessInfo.processInfo.arguments
|
||||
.first(where: { $0.hasPrefix(prefix) })?
|
||||
.dropFirst(prefix.count)
|
||||
return raw.flatMap { Scenario(rawValue: String($0)) } ?? .idle
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: { _ in }
|
||||
)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.onAppear {
|
||||
configure(width: proxy.size.width)
|
||||
}
|
||||
.onChange(of: proxy.size.width) { _, width in
|
||||
configureLayout(width: width)
|
||||
}
|
||||
}
|
||||
.background(backgroundColor.ignoresSafeArea())
|
||||
.onDisappear {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||
AIKeyboardView.debugKeepsSkillTip = false
|
||||
}
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Palette.dark.background : Palette.light.background
|
||||
}
|
||||
|
||||
private func configure(width: CGFloat) {
|
||||
configureLayout(width: width)
|
||||
guard !configured else { return }
|
||||
configured = true
|
||||
|
||||
state.surface = .voice
|
||||
state.phase = .idle
|
||||
state.aiServiceAvailable = true
|
||||
state.micDisabled = false
|
||||
state.returnKeyRole = .send
|
||||
|
||||
let keyboardState = state
|
||||
state.tapMic = { [weak keyboardState] in
|
||||
guard let keyboardState else { return }
|
||||
if case .recording = keyboardState.phase {
|
||||
keyboardState.phase = .idle
|
||||
} else {
|
||||
keyboardState.phase = .recording
|
||||
}
|
||||
}
|
||||
state.tapAIMic = { [weak keyboardState] in
|
||||
guard let keyboardState else { return }
|
||||
if keyboardState.aiSession.phase == .listening {
|
||||
if let utteranceID = keyboardState.aiSession.activeUtteranceID {
|
||||
keyboardState.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||
}
|
||||
return
|
||||
}
|
||||
keyboardState.aiSession.enter()
|
||||
let utteranceID = UUID()
|
||||
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
keyboardState.aiSession.beginListening(utteranceID: utteranceID)
|
||||
}
|
||||
state.sendAssistantAction = { [weak keyboardState] in
|
||||
keyboardState?.assistantSendAvailable = false
|
||||
}
|
||||
state.undoLastInsertion = { [weak keyboardState] in
|
||||
keyboardState?.undoAvailable = false
|
||||
}
|
||||
state.beginEditLastInput = { [weak keyboardState] in
|
||||
guard let keyboardState else { return }
|
||||
let reference = EditableInputReference(
|
||||
displayText: "Original dictated input",
|
||||
insertedText: "Original dictated input",
|
||||
postInsertionFingerprint: nil,
|
||||
extensionInstanceID: UUID()
|
||||
)
|
||||
keyboardState.editSession = .listening(EditSessionSource(reference: reference))
|
||||
keyboardState.phase = .recording
|
||||
}
|
||||
state.stopEditListening = { [weak keyboardState] in
|
||||
guard let keyboardState,
|
||||
let source = keyboardState.editSession.source else {
|
||||
return
|
||||
}
|
||||
keyboardState.editSession = .review(
|
||||
EditReview(
|
||||
source: source,
|
||||
resultText: "Edited dictated input",
|
||||
utteranceID: UUID()
|
||||
)
|
||||
)
|
||||
keyboardState.phase = .processing
|
||||
}
|
||||
state.confirmEditResult = { [weak keyboardState] in
|
||||
keyboardState?.editSession = .inactive
|
||||
keyboardState?.phase = .idle
|
||||
}
|
||||
state.submitAIHint = { [weak keyboardState] _ in
|
||||
guard let keyboardState else { return }
|
||||
let utteranceID = UUID()
|
||||
keyboardState.aiSession.enter()
|
||||
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
keyboardState.aiSession.beginGenerating(
|
||||
question: "Deterministic hint",
|
||||
utteranceID: utteranceID
|
||||
)
|
||||
}
|
||||
|
||||
switch scenario {
|
||||
case .idle:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
case .completed:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
state.assistantSendAvailable = true
|
||||
case .pending:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
let utteranceID = UUID()
|
||||
state.aiSession.enter()
|
||||
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
state.aiSession.receiveAnswer(
|
||||
"A retained answer that requires explicit insertion.",
|
||||
utteranceID: utteranceID
|
||||
)
|
||||
state.confirmPendingAIAnswer = { [weak keyboardState] in
|
||||
guard let keyboardState else { return }
|
||||
keyboardState.aiSession.markAnswerInserted(offersSend: true)
|
||||
keyboardState.undoAvailable = true
|
||||
keyboardState.editAvailable = true
|
||||
keyboardState.assistantSendAvailable = true
|
||||
}
|
||||
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||
keyboardState?.aiSession.discardReadyAnswer()
|
||||
}
|
||||
case .skillFailure:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.skillTipText = "Skill failed"
|
||||
case .skills:
|
||||
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
}
|
||||
}
|
||||
|
||||
private func configureLayout(width: CGFloat) {
|
||||
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||
state.layoutWidth = width
|
||||
state.usesIPadLayoutMetrics = isIPad
|
||||
state.showsSystemGlobeKey = isIPad
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -232,23 +232,6 @@ struct RememberLastSurfaceToggleRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cursor drag navigation toggle
|
||||
|
||||
struct CursorDragNavigationToggleRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Binding var isOn: Bool
|
||||
|
||||
var body: some View {
|
||||
Toggle(isOn: $isOn) {
|
||||
Text("settings.cursorDragNavigation.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu picker row (generic)
|
||||
|
||||
struct SettingsMenuPickerRow: View {
|
||||
|
||||
@@ -259,10 +259,6 @@ struct GeneralSettingsView: View {
|
||||
set: { config.keyboardHapticIntensity = $0 }
|
||||
)
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
CursorDragNavigationToggleRow(
|
||||
isOn: $config.cursorDragNavigationEnabled
|
||||
)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
@@ -310,9 +306,13 @@ struct AIAgentSettingsView: View {
|
||||
|
||||
struct ClipboardSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@ObservedObject private var history = ClipboardHistoryStore.shared
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -356,40 +356,58 @@ struct ClipboardSettingsView: View {
|
||||
// "Paste from Other Apps" permission to Allow.
|
||||
CardSection("settings.clipboard.paste.section") {
|
||||
VStack(spacing: 0) {
|
||||
Text("settings.clipboard.paste.body")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
AppPermissions.requestPasteAccess()
|
||||
} label: {
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
"settings.clipboard.paste.request",
|
||||
language: config.uiLanguage
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.paste.body")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if pasteAccessVerified {
|
||||
Label(
|
||||
"settings.clipboard.paste.verified",
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.fixedSize()
|
||||
.accessibilityIdentifier("settings.clipboard.paste.verified")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Divider().background(palette.divider)
|
||||
if !pasteAccessVerified {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
AppPermissions.openSystemSettings()
|
||||
} label: {
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
"settings.clipboard.paste.open",
|
||||
language: config.uiLanguage
|
||||
Button {
|
||||
verifyPasteAccess()
|
||||
} label: {
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
"settings.clipboard.paste.request",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.clipboard.paste.verify")
|
||||
|
||||
if pasteAccessNeedsRecovery {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
AppPermissions.openSystemSettings()
|
||||
} label: {
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
"settings.clipboard.paste.open",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.clipboard.paste.openSettings")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
@@ -441,8 +459,22 @@ struct ClipboardSettingsView: View {
|
||||
} message: {
|
||||
Text("settings.clipboard.clear.message")
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||
isPresented: $showPasteNoTextAlert
|
||||
) {
|
||||
Button("common.done") { showPasteNoTextAlert = false }
|
||||
} message: {
|
||||
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||
}
|
||||
.onAppear {
|
||||
history.reload()
|
||||
refreshPasteAccessState()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPasteAccessState()
|
||||
pasteAccessNeedsRecovery = false
|
||||
}
|
||||
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
@@ -457,6 +489,27 @@ struct ClipboardSettingsView: View {
|
||||
set: { config.clipboardCandidateBarEnabled = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func verifyPasteAccess() {
|
||||
switch AppPermissions.requestPasteAccess() {
|
||||
case .verified:
|
||||
withAnimation(Motion.quick) {
|
||||
pasteAccessVerified = true
|
||||
pasteAccessNeedsRecovery = false
|
||||
}
|
||||
case .noTextAvailable:
|
||||
showPasteNoTextAlert = true
|
||||
case .unavailable:
|
||||
withAnimation(Motion.quick) {
|
||||
pasteAccessVerified = false
|
||||
pasteAccessNeedsRecovery = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPasteAccessState() {
|
||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
@@ -205,15 +205,18 @@
|
||||
"settings.clipboard.subtitle.on" = "On";
|
||||
"settings.clipboard.subtitle.off" = "Off";
|
||||
"settings.clipboard.history.title" = "History";
|
||||
"settings.clipboard.history.footer" = "Off by default. Captures text copied on this device or through Universal Clipboard and keeps up to 15 items in this device’s App Group. AI mode can suggest clipboard-related prompts for about 30 seconds after a copy.";
|
||||
"settings.clipboard.history.footer" = "Keeps up to 15 copied text items on this device. Off by default.";
|
||||
"settings.clipboard.candidate.title" = "Suggestion strip";
|
||||
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
|
||||
"settings.clipboard.candidate.footer" = "Shows your latest copy above the keyboard. Tap to insert.";
|
||||
"settings.clipboard.paste.section" = "System access";
|
||||
"settings.clipboard.paste.body" = "Copy some text, tap Request Paste Access, and allow access. iOS will then create the Paste from Other Apps setting, where you can select Allow.";
|
||||
"settings.clipboard.paste.request" = "Request Paste Access";
|
||||
"settings.clipboard.paste.body" = "Allows clipboard skills to read text you copy.";
|
||||
"settings.clipboard.paste.request" = "Verify Paste Access";
|
||||
"settings.clipboard.paste.verified" = "Verified";
|
||||
"settings.clipboard.paste.open" = "Open iOS Settings";
|
||||
"settings.clipboard.storage.section" = "Local history";
|
||||
"settings.clipboard.storage.body" = "Turning History off stops capture and the suggestion strip but keeps saved items. Clipboard history does not sync through iCloud and is never sent to AI automatically. After you insert it, active polish may include it as context for your configured provider. Sensitive filtering is conservative and cannot detect every password.";
|
||||
"settings.clipboard.storage.body" = "History stays on this device, does not sync through iCloud, and is never sent to AI automatically. Turning History off keeps saved items.";
|
||||
"clipboard.paste.noText.title" = "Copy text first";
|
||||
"clipboard.paste.noText.message" = "Copy a short piece of text, then try again.";
|
||||
"settings.clipboard.clear.button" = "Clear clipboard history";
|
||||
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
||||
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
||||
@@ -440,9 +443,16 @@
|
||||
"skills.enabled.section" = "In use (%d/%d)";
|
||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||
"skills.available.section" = "Available";
|
||||
"skills.clipboard.guide.title" = "Clipboard access needed";
|
||||
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
|
||||
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
|
||||
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
||||
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
||||
"skills.clipboard.guide.verify.title" = "One more step";
|
||||
"skills.clipboard.guide.verify.body" = "Allow OSGKeyboard to read text you copy.";
|
||||
"skills.clipboard.guide.verify.action" = "Verify Paste Access";
|
||||
"skills.clipboard.guide.recovery.title" = "Paste access is off";
|
||||
"skills.clipboard.guide.recovery.body" = "Allow Paste from Other Apps in iOS Settings, then return to verify.";
|
||||
"skills.clipboard.guide.success.title" = "Clipboard skills are ready";
|
||||
"skills.clipboard.guide.success.body" = "Copy text to use them from the keyboard.";
|
||||
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||
"skills.detail.title" = "Skill";
|
||||
"skills.badge.default" = "Default skill";
|
||||
|
||||
@@ -205,15 +205,18 @@
|
||||
"settings.clipboard.subtitle.on" = "已开启";
|
||||
"settings.clipboard.subtitle.off" = "已关闭";
|
||||
"settings.clipboard.history.title" = "历史记录";
|
||||
"settings.clipboard.history.footer" = "默认关闭。开启后采集本机或通用剪贴板复制的文本,并在本机 App Group 中保存最近 15 条;复制约 30 秒内,AI 模式也可展示剪贴板相关建议。";
|
||||
"settings.clipboard.history.footer" = "在本机保存最近 15 条复制文字,默认关闭。";
|
||||
"settings.clipboard.candidate.title" = "建议条";
|
||||
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
|
||||
"settings.clipboard.candidate.footer" = "在键盘上方显示最新复制,轻点即可插入。";
|
||||
"settings.clipboard.paste.section" = "系统授权";
|
||||
"settings.clipboard.paste.body" = "先复制一段文字,再点「请求粘贴权限」并允许访问;iOS 随后会生成「从其他 App 粘贴」设置项,可将其设为「允许」。";
|
||||
"settings.clipboard.paste.request" = "请求粘贴权限";
|
||||
"settings.clipboard.paste.body" = "允许剪贴板技能读取你主动复制的文字。";
|
||||
"settings.clipboard.paste.request" = "验证粘贴访问";
|
||||
"settings.clipboard.paste.verified" = "已验证";
|
||||
"settings.clipboard.paste.open" = "打开系统设置";
|
||||
"settings.clipboard.storage.section" = "本机历史";
|
||||
"settings.clipboard.storage.body" = "关闭「历史记录」只会停止采集并关闭建议条,已有记录仍会保留。剪贴板历史不经 iCloud 同步,也不会自动发送给 AI;插入后若主动使用润色,内容可能作为上下文发送给你配置的服务商。敏感内容过滤采用保守规则,无法识别所有密码。";
|
||||
"settings.clipboard.storage.body" = "历史仅保存在本机,不经 iCloud 同步,也不会自动发送给 AI。关闭历史不会删除已有记录。";
|
||||
"clipboard.paste.noText.title" = "请先复制文字";
|
||||
"clipboard.paste.noText.message" = "复制一小段文字后,再试一次。";
|
||||
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
||||
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
||||
@@ -439,9 +442,16 @@
|
||||
"skills.enabled.section" = "使用中(%d/%d)";
|
||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||
"skills.available.section" = "可添加";
|
||||
"skills.clipboard.guide.title" = "需要剪贴板权限";
|
||||
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
|
||||
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
|
||||
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
||||
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
||||
"skills.clipboard.guide.verify.title" = "还差一步";
|
||||
"skills.clipboard.guide.verify.body" = "允许 OSGKeyboard 读取你主动复制的文字。";
|
||||
"skills.clipboard.guide.verify.action" = "验证粘贴访问";
|
||||
"skills.clipboard.guide.recovery.title" = "粘贴访问未开启";
|
||||
"skills.clipboard.guide.recovery.body" = "在系统设置中允许「从其他 App 粘贴」,返回后再验证。";
|
||||
"skills.clipboard.guide.success.title" = "剪贴板技能已就绪";
|
||||
"skills.clipboard.guide.success.body" = "复制文字后,即可从键盘使用这些技能。";
|
||||
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||
"skills.detail.title" = "技能";
|
||||
"skills.badge.default" = "默认技能";
|
||||
|
||||
Reference in New Issue
Block a user