diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78128b5..49d245e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: run: ./Scripts/generate-xcodeproj.sh - name: Run PR test preset run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr - - name: Run account and purchase UI regression + - name: Run simulator-stable UI regressions run: | set -euo pipefail xcodebuild \ @@ -71,6 +71,8 @@ jobs: -scheme OSGKeyboardUITests \ -destination 'platform=iOS Simulator,name=iPhone 17' \ -only-testing:OSGKeyboardUITests/AccountCenterUITests \ + -only-testing:OSGKeyboardUITests/AssistantKeyboardUITests \ + -only-testing:OSGKeyboardUITests/EditPagerUITests \ -onlyUsePackageVersionsFromResolvedFile \ test - name: Build Release diff --git a/AGENTS.md b/AGENTS.md index 12b03f3..dbd6324 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ writing commit messages that will ship to users. ### Version format -The current source-of-truth version is **2.0.1 (build 89)**. Releases use stable SemVer: +The current source-of-truth version is **2.0.3 (build 94)**. Releases use stable SemVer: | Field | File | Rule | |-------|------|------| diff --git a/CHANGELOG.md b/CHANGELOG.md index e6088bb..84dee8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Monthly usage calendar**: replace the seven-day Home and Mac Dashboard chart with a full current-month calendar whose date-circle opacity reflects each day's dictation characters, placing the Home calendar between its four metrics and Personal Dictionary. / **月度用量日历**:以完整的当月日历替换首页与 Mac 总览的近七天图表,并通过日期圆形背景透明度表示每天的听写字数;首页日历位于四项统计指标与个性词库之间。 +- **Home credits and navigation**: move the credit and invitation card from Settings to Home above the usage metrics, explain free signup and referral rewards before sign-in, and open History or Personal Dictionary directly from their metric cards. / **首页积分与导航**:将积分与邀请卡片从设置迁移到首页统计指标上方,登录前明确说明免费注册与邀请奖励,并支持从听写字数和词库指标卡片直接进入历史记录或个性词库。 +- **Personal style threshold**: allow generating a learned speaking style after 2,500 effective dictation characters instead of 5,000. / **专属风格门槛**:生成学习型说话风格所需的有效听写字符由 5,000 降至 2,500。 + +### Fixed +- **Recommended hotword details**: label Home recommendations by their actual source, render suggestion chips in adaptive monochrome, remove internal usage counts from dictionary rows, and anchor the iPhone confirmation popover to the selected hotword. / **推荐热词详情**:按真实来源标记首页推荐词,以自适应黑白配色显示推荐标签,移除词库列表中的内部使用次数,并让 iPhone 确认弹窗指向所选热词。 + +## [2.0.3] - 2026-08-24 + +### Changed +- **English personal-term suggestions**: learn repeated rare English words alongside Chinese typing habits, preserve existing Rime history, and ask before adding either language to the Personal Dictionary. / **英文个性词推荐**:在中文输入习惯之外学习重复使用的英文低频词,保留现有 Rime 历史,并在将任一语言加入个性词库前由用户确认。 +- **Interface consistency**: standardize borderless card spacing, compact settings typography, native menu alignment, account and purchase-history rows, and tab-bar transitions between primary and detail pages. / **界面一致性**:统一无描边卡片间距、紧凑设置字体、原生菜单对齐、账号与购买记录行,以及一级与详情页面之间的标签栏过渡。 + +### Fixed +- **Apple account continuity**: retry transient secure-session restoration after reinstall or foreground activation, and show a consistent loading state while Apple sign-in finishes. / **Apple 账号连续性**:在重装或重新进入前台后重试瞬时失败的安全会话恢复,并在 Apple 登录完成期间统一显示加载状态。 +- **Cross-app typing candidates**: isolate document-scoped candidate state from the reusable keyboard engine so English and Chinese candidates recover reliably after switching apps without leaking the previous field. / **跨 App 输入候选词**:将文档级候选状态与可复用键盘引擎分离,使中英文候选词在切换 App 后可靠恢复,且不会泄漏上一个输入框的状态。 + +## [2.0.2] - 2026-08-24 + ### Added - **Phone-number clipboard actions**: recognize a single copied phone number locally, then offer system calling or a new-contact form with the number prefilled. / **电话号码剪贴板操作**:在本地识别复制内容中的唯一电话号码,并提供系统拨号或预填号码的新建联系人表单。 - **Link-aware clipboard skills**: recognize a single copied web link locally, offer direct opening in the default browser, and securely extract bounded public webpage text in the host app before generating a summary. / **链接感知剪贴板技能**:在本地识别复制内容中的唯一网页链接,提供默认浏览器直接打开入口,并由主 App 安全、限量提取公开网页正文后生成总结。 @@ -15,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Learned speaking styles**: unlock user-initiated style generation after 5,000 effective dictation characters, prioritize recurring native speech and explicit user edits, use historical polish prompts only to subtract AI-added style, and require review before saving. / **学习说话风格**:累积 5,000 个有效听写字符后,可优先根据反复出现的原生口述与用户明确编辑生成个人风格,历史润色 Prompt 仅用于排除 AI 附加风格,并在保存前强制检查。 ### Changed +- **System semantic skills**: hide Reply, Decline Invitation, Empathy Reply, Accept Invitation, Call, Create Contact, and Clarify from the host Skills catalog while keeping them available for automatic keyboard recommendations. / **系统语义技能**:在主 App 技能目录中隐藏“回复、委婉拒绝、共情回复、接受邀约、拨打电话、新建联系人、澄清与追问”,同时保留键盘按语义自动推荐这些操作的能力。 +- **Required default clipboard skills**: install Reply, Translate, Summarize, Decline Invitation, Clarify, Empathy Reply, and Organize List once for existing layouts, with system-action management visibility handled separately. / **必备默认剪贴板技能**:为现有布局一次性补装“回复、翻译、总结要点、委婉拒绝、澄清与追问、共情回复、整理清单”,系统动作是否显示在技能管理中由独立策略控制。 - **Focused clipboard actions**: translate foreign clipboard text into the device's primary system language, consolidate overlapping reply, summary, and clarification skills with legacy-layout migration, distinguish webpage summaries, and avoid playful suggestions for negative messages. / **聚焦剪贴板操作**:将外语剪贴板文本翻译为设备首选系统语言,兼容迁移并整合重复的回复、总结与澄清技能,区分网页总结,并避免为负面消息推荐俏皮回复。 - **Personal dictionary empty state**: remove the Rime explanation from the Home card and shorten its empty message to “No suggestions yet.” / **个性词库空状态**:移除首页卡片中的 Rime 说明,并将空状态精简为“暂无推荐词”。 - **Skill catalog layout**: replace the two-column cards with scroll-safe installed and uninstalled lists; selecting a row now opens a full detail page where installation, Shortcut setup, and custom-skill editing are managed. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑。 @@ -22,6 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。 ### Fixed +- **Personal dictionary aliases**: generate common speech-recognition mistakes for Home term suggestions through the same concurrency-safe save path used by manual entries. / **个性词库易错词**:首页推荐词现在与手动词条共用并发安全的保存流程,并自动生成常见语音误识别写法。 +- **Home service setup guidance**: explain whether user-owned API keys are missing, link directly to the relevant provider settings, and offer OSG credits as the no-key alternative. / **首页服务配置引导**:明确说明缺少哪类自备 API Key,直接跳转到对应服务商设置,并提供无需填写 Key 的 OSG 积分方案。 +- **Live keyboard configuration guidance**: apply speech-language changes to an already open keyboard and explain when clipboard capture is paused because Full Access is off. / **键盘实时配置引导**:识别语言变更会同步到已打开的键盘,并在“允许完全访问”关闭导致剪贴板采集暂停时给出说明。 +- **Clipboard skill fallback**: keep Reply available for every accepted clipboard text, including while local semantic analysis is still running or finds no specialized action. / **剪贴板技能兜底**:任何已接收的剪贴板文本都会保留“回复”,包括本地语义分析尚未完成或没有识别出专用操作时。 - **Repeatable OOBE practice**: scope each free feature to one use per short-lived guided session, so returning to OOBE starts a fresh four-page experience without misreporting consumed-page conflicts as weak network; also simplify lesson titles, add a voice sample to read aloud, and streamline the completion page. / **可重复的 OOBE 体验**:将每项免费功能限制为每个短期引导会话使用一次,让用户重新进入 OOBE 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。 - **Managed hotword answers**: run keyboard hotwords with the same DeepSeek thinking policy as hold-to-ask AI, require online search for time-sensitive cards, preserve hotword usage attribution, and distinguish credit, provider, timeout, and gateway failures. / **积分热词回答**:键盘热词现在采用与长按问 AI 相同的 DeepSeek 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。 diff --git a/OSGKeyboard/AppIcon.icon/icon.json b/OSGKeyboard/AppIcon.icon/icon.json index 7cea085..7e4a252 100644 --- a/OSGKeyboard/AppIcon.icon/icon.json +++ b/OSGKeyboard/AppIcon.icon/icon.json @@ -11,7 +11,7 @@ "image-name" : "OSGKeyboard Icon2.png", "name" : "OSGKeyboard Icon2", "position" : { - "scale" : 0.505, + "scale" : 0.508, "translation-in-points" : [ 8, 6 diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift index b57d033..c0e9e9c 100644 --- a/OSGKeyboard/Views/AIAgentSkillsView.swift +++ b/OSGKeyboard/Views/AIAgentSkillsView.swift @@ -9,6 +9,10 @@ import Foundation import OSGKeyboardShared import SwiftUI +private enum SkillRoute: Hashable { + case detail(String) +} + struct AIAgentSkillsView: View { @Environment(\.themePalette) private var palette @Environment(\.scenePhase) private var scenePhase @@ -20,20 +24,21 @@ struct AIAgentSkillsView: View { @State private var pasteAccessNeedsRecovery = false @State private var showPasteNoTextAlert = false @State private var showPasteAccessSuccess = false + @State private var path = NavigationPath() private var skillCardShape: RoundedRectangle { RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) } var body: some View { - NavigationStack { + NavigationStack(path: $path) { ScrollView { - CardPageContent(spacing: Spacing.xl) { + CardPageContent { if showsClipboardAccessGuide { clipboardAccessGuide .transition(.opacity.combined(with: .move(edge: .top))) } installedSection - if !store.availableSkills.isEmpty { + if !store.skillManagementAvailableSkills.isEmpty { uninstalledSection } } @@ -42,6 +47,19 @@ struct AIAgentSkillsView: View { .background(palette.background) .navigationTitle("skills.title") .navigationBarTitleDisplayMode(.large) + .navigationDestination(for: SkillRoute.self) { route in + switch route { + case .detail(let skillID): + if let skill = store.mergedCatalog.first(where: { $0.id == skillID }) { + skillDetailView(skill) + } else { + ContentUnavailableView( + "skills.installed.empty", + systemImage: "wand.and.sparkles" + ) + } + } + } .toolbar { ToolbarItem(placement: .topBarTrailing) { Button { @@ -53,6 +71,7 @@ struct AIAgentSkillsView: View { } } } + .navigationStackTabBarVisibility(isRoot: path.isEmpty) .sheet(item: $editingDraft) { draft in SkillEditorSheet( draft: draft, @@ -94,11 +113,7 @@ struct AIAgentSkillsView: View { private var clipboardAccessGuide: some View { VStack(alignment: .leading, spacing: Spacing.sm) { 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()) + skillIcon(systemImage: clipboardGuideIcon) VStack(alignment: .leading, spacing: 4) { Text(clipboardGuideTitle) @@ -126,7 +141,6 @@ struct AIAgentSkillsView: View { .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") } @@ -138,7 +152,8 @@ struct AIAgentSkillsView: View { HStack(spacing: Spacing.sm) { Image(systemName: systemImage) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(palette.accent) + .foregroundStyle(palette.textPrimary) + .symbolRenderingMode(.monochrome) .frame(width: 22) Text(titleKey) .font(TypeStyle.body) @@ -194,10 +209,6 @@ struct AIAgentSkillsView: View { 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" @@ -288,53 +299,36 @@ struct AIAgentSkillsView: View { title: AppL10n.format( "skills.installed.section", language: config.uiLanguage, - store.enabledSkills.count + store.skillManagementEnabledSkills.count ) ) { - if store.enabledSkills.isEmpty { + if store.skillManagementEnabledSkills.isEmpty { Text("skills.installed.empty") .font(TypeStyle.caption) .foregroundStyle(palette.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) } else { - LazyVStack(spacing: 0) { - ForEach(store.enabledSkills) { skill in + LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) { + ForEach(store.skillManagementEnabledSkills) { skill in skillListItem(skill) } } - .background(palette.surface, in: skillCardShape) - .overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5)) - .clipShape(skillCardShape) } } } private var uninstalledSection: some View { CardSection("skills.uninstalled.section") { - LazyVStack(spacing: 0) { - ForEach(store.availableSkills) { skill in + LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) { + ForEach(store.skillManagementAvailableSkills) { skill in skillListItem(skill) } } - .background(palette.surface, in: skillCardShape) - .overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5)) - .clipShape(skillCardShape) } } private func skillListItem(_ skill: AIClipboardSkill) -> some View { - NavigationLink { - SkillDetailView( - store: store, - skill: skill, - onInstall: addOrWarn, - onConfirmInstall: confirmShortcutInstall, - onEdit: { - guard let user = store.userSkill(id: skill.id) else { return } - editingDraft = .from(user) - } - ) - } label: { + NavigationLink(value: SkillRoute.detail(skill.id)) { skillListRow(skill) } .buttonStyle(.plain) @@ -349,14 +343,7 @@ struct AIAgentSkillsView: View { private func skillListRow(_ skill: AIClipboardSkill) -> some View { HStack(spacing: Spacing.md) { - Image(systemName: skill.systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(palette.accent) - .frame(width: 38, height: 38) - .background(palette.accentMuted, in: RoundedRectangle( - cornerRadius: Radius.medium, - style: .continuous - )) + skillIcon(systemImage: skill.systemImage) VStack(alignment: .leading, spacing: 3) { Text(skillTitle(skill)) @@ -378,16 +365,36 @@ struct AIAgentSkillsView: View { .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) .padding(.horizontal, Spacing.md) .padding(.vertical, Spacing.xs) - .background(palette.surface) - .overlay(alignment: .bottom) { - Rectangle() - .fill(palette.divider) - .frame(height: 0.5) - .padding(.leading, 38 + Spacing.md * 2) - } + .background(palette.surface, in: skillCardShape) + .clipShape(skillCardShape) .contentShape(Rectangle()) } + private func skillIcon(systemImage: String) -> some View { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .symbolRenderingMode(.monochrome) + .frame(width: 38, height: 38) + .background( + palette.textPrimary.opacity(0.08), + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) + } + + private func skillDetailView(_ skill: AIClipboardSkill) -> some View { + SkillDetailView( + store: store, + skill: skill, + onInstall: addOrWarn, + onConfirmInstall: confirmShortcutInstall, + onEdit: { + guard let user = store.userSkill(id: skill.id) else { return } + editingDraft = .from(user) + } + ) + } + private func skillTitle(_ skill: AIClipboardSkill) -> String { if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty { @@ -505,7 +512,6 @@ private struct SkillDetailView: View { var body: some View { ScrollView { CardPageContent( - spacing: Spacing.md, topPadding: Spacing.md, bottomPadding: Spacing.xl ) { @@ -555,9 +561,12 @@ private struct SkillDetailView: View { HStack(alignment: .center, spacing: Spacing.md) { Image(systemName: skill.systemImage) .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(palette.accent) - .frame(width: 36, height: 36) - .background(palette.accentMuted, in: Circle()) + .foregroundStyle(palette.textPrimary) + .frame(width: 38, height: 38) + .background( + palette.textPrimary.opacity(0.08), + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) VStack(alignment: .leading, spacing: 2) { Text(headerTitle) .font(TypeStyle.title3) @@ -660,6 +669,7 @@ private struct SkillDetailView: View { prominent: Bool, action: @escaping () -> Void ) -> some View { + let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous) Button(action: action) { Text(titleKey) .font(TypeStyle.bodyEmph) @@ -667,11 +677,7 @@ private struct SkillDetailView: View { .foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary) .background( prominent ? palette.accent : palette.surfaceElevated, - in: Capsule() - ) - .overlay( - Capsule() - .stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5) + in: shape ) } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift index 0779d12..4cf387c 100644 --- a/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift +++ b/OSGKeyboard/Views/Account/AccountAppleAuthorizationButton.swift @@ -25,25 +25,56 @@ struct AccountAppleAuthorizationButton: View { @State private var rawNonce: String? var body: some View { - SignInWithAppleButton( - purpose == .signIn ? .signIn : .continue, - onRequest: prepareRequest, - onCompletion: completeAuthorization - ) - .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) - .frame(maxWidth: .infinity) - .frame(height: 50) - .clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .accessibilityLabel( - purpose == .signIn - ? Text("account.signIn.apple") - : Text("account.delete.reauthenticate") - ) - .accessibilityHint( - purpose == .signIn - ? Text("account.signIn.hint") - : Text("account.delete.reauthenticateHint") - ) + Group { + if purpose == .signIn, coordinator.operation == .signingIn { + HStack(spacing: Spacing.sm) { + ProgressView() + .tint(authorizationButtonForeground) + Text("account.signIn.loading") + .font(TypeStyle.bodyEmph) + } + .foregroundStyle(authorizationButtonForeground) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(authorizationButtonBackground) + .clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("account.signIn.loading") + } else { + SignInWithAppleButton( + purpose == .signIn ? .signIn : .continue, + onRequest: prepareRequest, + onCompletion: completeAuthorization + ) + .signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black) + .frame(maxWidth: .infinity) + .frame(height: 50) + .clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .accessibilityLabel( + purpose == .signIn + ? Text("account.signIn.apple") + : Text("account.delete.reauthenticate") + ) + .accessibilityHint( + purpose == .signIn + ? Text("account.signIn.hint") + : Text("account.delete.reauthenticateHint") + ) + .accessibilityIdentifier( + purpose == .signIn + ? "account.signIn.apple" + : "account.delete.reauthenticate" + ) + } + } + } + + private var authorizationButtonBackground: Color { + colorScheme == .dark ? .white : .black + } + + private var authorizationButtonForeground: Color { + colorScheme == .dark ? .black : .white } private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) { diff --git a/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift b/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift index e104bd4..d9b7f4b 100644 --- a/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift +++ b/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift @@ -12,13 +12,20 @@ import SwiftUI @MainActor struct AccountCenterUITestHarness: View { @StateObject private var coordinator: AccountSessionCoordinator + private let startsSignIn: Bool init() { + let startsSignIn = ProcessInfo.processInfo.arguments.contains( + "--account-signin-loading" + ) let service = AccountCenterUITestService( shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains( "--account-snapshot-failure" - ) + ), + startsSignedOut: startsSignIn, + signInDelay: startsSignIn ? .seconds(5) : .zero ) + self.startsSignIn = startsSignIn let store = AccountCenterUITestCreditStore(accountID: service.account.accountID) _coordinator = StateObject( wrappedValue: AccountSessionCoordinator( @@ -41,6 +48,15 @@ struct AccountCenterUITestHarness: View { } .task { await coordinator.restoreIfNeeded() + if startsSignIn { + await coordinator.signIn( + with: AppleAuthorizationPayload( + identityToken: "ui-test-identity", + authorizationCode: "ui-test-authorization", + nonce: "ui-test-nonce" + ) + ) + } } } } @@ -96,17 +112,28 @@ private actor AccountCenterUITestService: displayName: "UI Test Account" ) private let shouldFailSnapshot: Bool + private let startsSignedOut: Bool + private let signInDelay: Duration - init(shouldFailSnapshot: Bool = false) { + init( + shouldFailSnapshot: Bool = false, + startsSignedOut: Bool = false, + signInDelay: Duration = .zero + ) { self.shouldFailSnapshot = shouldFailSnapshot + self.startsSignedOut = startsSignedOut + self.signInDelay = signInDelay } func restoreSession() async throws -> AccountSession? { - account + startsSignedOut ? nil : account } func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession { _ = payload + if signInDelay > .zero { + try await Task.sleep(for: signInDelay) + } return account } diff --git a/OSGKeyboard/Views/Account/AccountCenterView.swift b/OSGKeyboard/Views/Account/AccountCenterView.swift index 9f446a8..50c215c 100644 --- a/OSGKeyboard/Views/Account/AccountCenterView.swift +++ b/OSGKeyboard/Views/Account/AccountCenterView.swift @@ -26,7 +26,6 @@ struct AccountCenterView: View { } .navigationTitle("account.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .task(id: coordinator.accountID) { guard let accountID = coordinator.accountID else { coordinator.creditPurchases.reset() @@ -116,11 +115,6 @@ struct AccountCenterView: View { AccountAppleAuthorizationButton(purpose: .signIn) .disabled(coordinator.operation != nil) - if coordinator.operation == .signingIn { - ProgressView("account.signIn.loading") - .tint(palette.accent) - } - if coordinator.pendingReferralCode != nil { Label("account.referral.pendingAfterSignIn", systemImage: "link") .font(.footnote) @@ -273,7 +267,6 @@ struct AccountCenterView: View { } .padding(Spacing.lg) .background(palette.surface, in: shape) - .overlay(shape.stroke(palette.divider, lineWidth: 0.5)) .accessibilityIdentifier("account.summary") } @@ -954,9 +947,12 @@ private struct AccountActionRow: View { let showsProgress: Bool var body: some View { - HStack(spacing: Spacing.sm) { - Label(titleKey, systemImage: systemImage) - .font(.body) + HStack(spacing: Spacing.xs) { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .regular)) + .frame(width: 20) + Text(titleKey) + .font(TypeStyle.body) Spacer(minLength: Spacing.xs) if showsProgress { ProgressView() diff --git a/OSGKeyboard/Views/Account/AccountPurchaseHistoryView.swift b/OSGKeyboard/Views/Account/AccountPurchaseHistoryView.swift index aa326ed..611fc05 100644 --- a/OSGKeyboard/Views/Account/AccountPurchaseHistoryView.swift +++ b/OSGKeyboard/Views/Account/AccountPurchaseHistoryView.swift @@ -8,6 +8,10 @@ import OSGKeyboardShared import SwiftUI struct AccountPurchaseHistoryView: View { + private enum Layout { + static let purchaseRowMinHeight: CGFloat = 64 + } + @Environment(\.themePalette) private var palette @ObservedObject private var config = ProviderConfig.shared @ObservedObject var manager: AccountCreditPurchaseManager @@ -56,7 +60,6 @@ struct AccountPurchaseHistoryView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("account.purchaseHistory.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .task { await manager.loadPurchaseHistory(accountID: accountID) } @@ -69,6 +72,14 @@ struct AccountPurchaseHistoryView: View { ForEach(records) { record in purchaseRow(record) .listRowBackground(palette.surface) + .listRowInsets( + EdgeInsets( + top: 0, + leading: Spacing.md, + bottom: 0, + trailing: Spacing.md + ) + ) .listRowSeparatorTint(palette.divider) .task { guard record.id == records.last?.id else { return } @@ -111,8 +122,8 @@ struct AccountPurchaseHistoryView: View { private func purchaseRow( _ record: AccountCreditPurchaseRecord ) -> some View { - HStack(alignment: .firstTextBaseline, spacing: Spacing.md) { - VStack(alignment: .leading, spacing: Spacing.xxs) { + HStack(alignment: .center, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: 2) { HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) { Text("+") .font(TypeStyle.bodyEmph) @@ -121,27 +132,32 @@ struct AccountPurchaseHistoryView: View { .font(TypeStyle.bodyEmph.monospacedDigit()) .foregroundStyle(palette.textPrimary) Text("account.storekit.credits") - .font(TypeStyle.caption) + .font(TypeStyle.body) .foregroundStyle(palette.textSecondary) } Text(purchaseDateText(record.purchasedAt)) - .font(TypeStyle.caption) + .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) } Spacer(minLength: Spacing.xs) - VStack(alignment: .trailing, spacing: Spacing.xxs) { + VStack(alignment: .trailing, spacing: 2) { Text(balanceAfterText(record.balanceAfter)) - .font(TypeStyle.caption.monospacedDigit()) + .font(TypeStyle.body.monospacedDigit()) .foregroundStyle(palette.textSecondary) Text("account.purchaseHistory.status.credited") - .font(TypeStyle.caption.weight(.semibold)) + .font(TypeStyle.caption2.weight(.semibold)) .foregroundStyle(palette.success) } } - .padding(.vertical, Spacing.xs) + .frame( + maxWidth: .infinity, + minHeight: Layout.purchaseRowMinHeight, + alignment: .center + ) .accessibilityElement(children: .combine) + .accessibilityIdentifier("account.purchaseHistory.row.\(record.transactionID)") } private func purchaseDateText(_ date: Date) -> String { diff --git a/OSGKeyboard/Views/Account/AccountRewardsCard.swift b/OSGKeyboard/Views/Account/AccountRewardsCard.swift new file mode 100644 index 0000000..3ad6ca7 --- /dev/null +++ b/OSGKeyboard/Views/Account/AccountRewardsCard.swift @@ -0,0 +1,277 @@ +// AccountRewardsCard.swift +// OSGKeyboard · Main App +// +// Compact Home account surface. Signed-in users see the same credit progress +// and invitation controls formerly shown in Settings; signed-out users see +// the concrete free-credit paths before authenticating. + +import OSGKeyboardShared +import SwiftUI + +struct AccountRewardsCard: View { + @Environment(\.themePalette) private var palette + @EnvironmentObject private var coordinator: AccountSessionCoordinator + @ObservedObject private var config = ProviderConfig.shared + + let onOpenAccount: () -> Void + + var body: some View { + Group { + switch coordinator.sessionPhase { + case .restoring: + loadingContent("account.settings.restoring") + case .signedOut: + signedOutContent + case .signedIn: + signedInContent + } + } + .surfaceCard() + .task(id: coordinator.accountID) { + guard coordinator.isSignedIn else { return } + await coordinator.refreshAccountData() + } + .alert("account.error.title", isPresented: operationErrorBinding) { + Button("common.done") { + coordinator.dismissOperationError() + } + } message: { + if let key = coordinator.operationErrorKey { + Text(LocalizedStringKey(key)) + } + } + } + + private var signedOutContent: some View { + VStack(alignment: .leading, spacing: Spacing.md) { + HStack(alignment: .center, spacing: Spacing.md) { + Image(systemName: "gift") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .symbolRenderingMode(.monochrome) + .frame(width: 38, height: 38) + .background( + palette.textPrimary.opacity(0.08), + in: RoundedRectangle( + cornerRadius: Radius.medium, + style: .continuous + ) + ) + + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("home.accountRewards.signedOut.title") + .font(TypeStyle.headline) + .foregroundStyle(palette.textPrimary) + Text("home.accountRewards.signedOut.summary") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + AccountAppleAuthorizationButton(purpose: .signIn) + .disabled(coordinator.operation != nil) + } + .padding(Spacing.md) + } + + private var signedInContent: some View { + VStack(spacing: 0) { + switch coordinator.snapshotPhase { + case let .loaded(snapshot): + creditSummary(snapshot) + case .failed: + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("account.error.load") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + Button("account.retry") { + Task { await coordinator.refreshAccountData(force: true) } + } + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.accent) + } + .padding(Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + case .idle, .loading: + loadingContent("account.loading.center") + } + + Divider().background(palette.divider) + AccountReferralLinkView(viewModel: coordinator.referralProfile) + } + } + + private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View { + let totalCredits = creditTotal(snapshot) + return Button(action: onOpenAccount) { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(alignment: .top, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(creditRemainingText(snapshot.credits.balance)) + .font(TypeStyle.title3.monospacedDigit()) + .foregroundStyle(palette.textPrimary) + Text("account.credits.balance") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + } + + Spacer(minLength: Spacing.xs) + + VStack(alignment: .trailing, spacing: Spacing.xxs) { + Text(creditUsedText(snapshot.credits.usedCredits)) + Text(creditTotalText(totalCredits)) + } + .font(TypeStyle.caption) + .monospacedDigit() + .foregroundStyle(palette.textSecondary) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(minWidth: 32, minHeight: 32) + .accessibilityHidden(true) + } + + AccountCreditProgress( + remaining: snapshot.credits.balance, + used: snapshot.credits.usedCredits, + showsLabels: false + ) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(Spacing.md) + } + + private func loadingContent(_ key: LocalizedStringKey) -> some View { + HStack(spacing: Spacing.sm) { + ProgressView() + .tint(palette.accent) + Text(key) + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + Spacer() + } + .settingsListRow() + } + + private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 { + let remaining = max(snapshot.credits.balance, 0) + let used = max(snapshot.credits.usedCredits, 0) + let (total, overflow) = remaining.addingReportingOverflow(used) + return overflow ? Int64.max : total + } + + private func creditRemainingText(_ remaining: Int64) -> String { + AppL10n.format( + "account.credits.remainingCompact", + language: config.uiLanguage, + max(remaining, 0).formatted(.number.grouping(.automatic)) + ) + } + + private func creditUsedText(_ used: Int64) -> String { + AppL10n.format( + "account.credits.usedCompact", + language: config.uiLanguage, + max(used, 0).formatted(.number.grouping(.automatic)) + ) + } + + private func creditTotalText(_ total: Int64) -> String { + AppL10n.format( + "account.credits.totalValueCompact", + language: config.uiLanguage, + total.formatted(.number.grouping(.automatic)) + ) + } + + private var operationErrorBinding: Binding { + Binding( + get: { coordinator.operationErrorKey != nil }, + set: { isPresented in + if !isPresented { + coordinator.dismissOperationError() + } + } + ) + } +} + +private struct AccountReferralLinkView: View { + @Environment(\.themePalette) private var palette + @ObservedObject var viewModel: ReferralProfileViewModel + + var body: some View { + Group { + switch viewModel.state { + case .idle: + retryRow( + messageKey: "account.referral.loadLink", + actionKey: "account.referral.loadLink" + ) + case .loading: + HStack(spacing: Spacing.sm) { + ProgressView() + .controlSize(.small) + .tint(palette.accent) + Text("account.referral.loadingLink") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + } + case .failed(let messageKey): + retryRow(messageKey: messageKey, actionKey: "account.retry") + case .loaded(let profile): + loadedContent(profile) + } + } + .padding(Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("account.referral.profile") + } + + private func loadedContent(_ profile: ReferralProfile) -> some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(spacing: Spacing.md) { + Text("account.referral.equalRewardDescription") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: Spacing.xs) + AccountInvitationButton(invitationURL: profile.code.inviteURL) + } + + if viewModel.isRefreshing { + HStack(spacing: Spacing.xs) { + ProgressView() + .controlSize(.mini) + Text("account.referral.refreshingLink") + } + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + } else if let refreshErrorKey = viewModel.refreshErrorKey { + retryRow(messageKey: refreshErrorKey, actionKey: "account.retry") + } + } + } + + private func retryRow( + messageKey: String, + actionKey: LocalizedStringKey + ) -> some View { + HStack(spacing: Spacing.sm) { + Text(LocalizedStringKey(messageKey)) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + Spacer(minLength: Spacing.xs) + Button(actionKey) { + Task { await viewModel.refresh() } + } + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.accent) + } + } +} diff --git a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift index 8c221ee..539b24b 100644 --- a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift +++ b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift @@ -139,13 +139,21 @@ final class AccountSessionCoordinator: ObservableObject { await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) } catch { - await sessionService.clearManagedGateway() - await onAccountSignedOut() - sessionPhase = .signedOut operationErrorKey = errorMessageKey( for: error, fallback: "account.error.restore" ) + if error as? AccountIntegrationError == .unavailable { + await sessionService.clearManagedGateway() + await onAccountSignedOut() + sessionPhase = .signedOut + } else { + // Keychain protection and transport availability can be + // transient during launch. Keep the restoring state and allow + // the next foreground activation to retry instead of turning + // a retained, valid session into a signed-out session. + didAttemptRestore = false + } } } diff --git a/OSGKeyboard/Views/Account/LiveAccountServices.swift b/OSGKeyboard/Views/Account/LiveAccountServices.swift index 478fa2d..a76a374 100644 --- a/OSGKeyboard/Views/Account/LiveAccountServices.swift +++ b/OSGKeyboard/Views/Account/LiveAccountServices.swift @@ -213,26 +213,47 @@ private actor LiveAccountService: func restoreSession() async throws -> AccountSession? { guard let cachedSession = try await apiClient.currentSession() else { + OSGDiag.log("session restore status=not-found", category: "account") await invalidateManagedGatewaySession() return nil } + OSGDiag.log("session restore status=keychain-found", category: "account") let account: OSGAccount? do { account = try await apiClient.account() } catch let error as AccountAPIError { switch error { case .sessionUnavailable, .unauthorized, .refreshTokenReuse: + OSGDiag.log( + "session restore status=rejected " + + "error=\(AccountDiagnostic.code(for: error))", + category: "account" + ) await invalidateManagedGatewaySession() return nil default: + OSGDiag.log( + "session restore status=cached-fallback " + + "error=\(AccountDiagnostic.code(for: error))", + category: "account" + ) account = nil } } catch { + OSGDiag.log( + "session restore status=cached-fallback " + + "error=\(AccountDiagnostic.code(for: error))", + category: "account" + ) account = nil } let session = try await apiClient.currentSession() ?? cachedSession configuration.setManagedGatewayAccountSessionAvailable(true) await synchronizeManagedGrant() + OSGDiag.log( + "session restore status=restored accountValidated=\(account == nil ? 0 : 1)", + category: "account" + ) return uiSession( session, createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0, diff --git a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift index 71634dd..68d2a08 100644 --- a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift +++ b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift @@ -174,7 +174,20 @@ struct AssistantKeyboardUITestHarness: View { AIKeyboardView.debugPreviewSkills = nil state.skillTipText = "Skill failed" case .skills: - AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog + // Keep the pagination assertion independent from production catalog + // ordering: Navigate is guaranteed to appear after one swipe on both + // four- and five-item pages. + let navigate = AIClipboardSkillCatalog.catalog.first { + $0.id == AIClipboardSkillCatalog.navigateID + } + let leading = AIClipboardSkillCatalog.catalog + .filter { $0.id != AIClipboardSkillCatalog.navigateID } + .prefix(5) + var previewSkills = Array(leading) + if let navigate { + previewSkills.append(navigate) + } + AIKeyboardView.debugPreviewSkills = previewSkills state.undoAvailable = true state.editAvailable = true case .search: diff --git a/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift index ce21985..76e8a8f 100644 --- a/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift +++ b/OSGKeyboard/Views/Components/HomeUsageStatsSection.swift @@ -3,7 +3,7 @@ // // Observes usage + dictionary counts and feeds the shared // `UsageStatsCluster` (phone stacked / iPad split). Optional `header` -// (e.g. glass preview field) sits on the 7-day chart card. +// (e.g. glass preview field) sits on the monthly calendar card. import OSGKeyboardHostSupport import OSGKeyboardShared @@ -12,6 +12,9 @@ import SwiftUI struct HomeUsageStatsSection: View { let layout: UsageStatsClusterLayout var compact: Bool = false + var content: UsageStatsClusterContent = .all + var onOpenHistory: (() -> Void)? + var onOpenDictionary: (() -> Void)? @ViewBuilder var header: () -> Header @ObservedObject private var stats = UsageStatisticsStore.shared @@ -23,12 +26,15 @@ struct HomeUsageStatsSection: View { UsageStatsCluster( layout: layout, language: config.uiLanguage, - points: stats.last7Days, + points: stats.currentMonth, dictationCharacterCount: stats.dictationCharacterCount, dictationDurationSeconds: stats.dictationDurationSeconds, translationCharacterCount: stats.translationCharacterCount, dictionaryTermCount: dictionaryCount, compact: compact, + content: content, + onOpenHistory: onOpenHistory, + onOpenDictionary: onOpenDictionary, header: header ) .onAppear(perform: refreshDictionaryCount) @@ -49,8 +55,21 @@ struct HomeUsageStatsSection: View { } extension HomeUsageStatsSection where Header == EmptyView { - init(layout: UsageStatsClusterLayout, compact: Bool = false) { - self.init(layout: layout, compact: compact, header: { EmptyView() }) + init( + layout: UsageStatsClusterLayout, + compact: Bool = false, + content: UsageStatsClusterContent = .all, + onOpenHistory: (() -> Void)? = nil, + onOpenDictionary: (() -> Void)? = nil + ) { + self.init( + layout: layout, + compact: compact, + content: content, + onOpenHistory: onOpenHistory, + onOpenDictionary: onOpenDictionary, + header: { EmptyView() } + ) } } diff --git a/OSGKeyboard/Views/Components/OSGAnimatedMeshBackground.swift b/OSGKeyboard/Views/Components/OSGAnimatedMeshBackground.swift new file mode 100644 index 0000000..9b3cfd3 --- /dev/null +++ b/OSGKeyboard/Views/Components/OSGAnimatedMeshBackground.swift @@ -0,0 +1,201 @@ +// OSGAnimatedMeshBackground.swift +// OSGKeyboard · Main App +// +// A slowly-flowing MeshGradient background (iOS 18+, deployment target iOS 26). +// System-native Metal acceleration, no third-party dependencies, 0 KB of assets. +// +// Design notes: +// - 3×3 grid keeps render cost low; corner points stay fixed (no clipping at edges). +// - Only the 4 inner control points + 4 mid-edge points drift; this avoids the +// gradient "snapping" at the edges of the parent view. +// - `TimelineView(.animation(minimumInterval: 1.0/30.0))` caps redraw at 30 fps +// (backgrounds do not need 120 Hz) — roughly halves GPU vs. uncapped TimelineView. +// - `accessibilityReduceMotion` → static frame at phase = 0 (Apple HIG). +// - Palette is the same near-black base used across the rest of the app +// (`Color(red: 0.06, green: 0.06, blue: 0.07)`) with cool-warm accents that +// sit in the "premium, calm, late-night" register — fits the Typeless vibe. + +import SwiftUI + +@available(iOS 18.0, *) +struct OSGAnimatedMeshBackground: View { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Animation period in seconds for a full color cycle. Longer = calmer. + var period: Double = 14.0 + + /// Optional palette override. Defaults to a deep indigo / amber / teal set. + var palette: AnimatedMeshPalette = .aurora + + /// Pause the animation at the current frame. Useful for App Store + /// screenshot capture or for "preview" hosts that want a still image. + var paused: Bool = false + + var body: some View { + if reduceMotion || paused { + MeshGradient( + width: 3, + height: 3, + points: Self.staticPoints, + colors: palette.colors, + smoothsColors: true, + colorSpace: .perceptual + ) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0, paused: false)) { context in + let phase = context.date.timeIntervalSinceReferenceDate / period + MeshGradient( + width: 3, + height: 3, + points: Self.animatedPoints(phase: phase), + colors: Self.animatedColors(base: palette.colors, phase: phase), + smoothsColors: true, + colorSpace: .perceptual + ) + } + } + } +} + +// MARK: - Geometry + +@available(iOS 18.0, *) +private extension OSGAnimatedMeshBackground { + /// 9 SIMD2 control points for a 3×3 mesh, top-left → bottom-right, row-major. + /// Corner points (0, 3, 6, 8) stay fixed; the rest drift on a slow sinusoid. + static let staticPoints: [SIMD2] = [ + SIMD2(0.0, 0.0), SIMD2(0.5, 0.0), SIMD2(1.0, 0.0), + SIMD2(0.0, 0.5), SIMD2(0.5, 0.5), SIMD2(1.0, 0.5), + SIMD2(0.0, 1.0), SIMD2(0.5, 1.0), SIMD2(1.0, 1.0) + ] + + static func animatedPoints(phase: Double) -> [SIMD2] { + // Use distinct incommensurable frequencies so the loop never visibly + // repeats inside a normal viewing session. + let p = Float(phase) + let cos1 = cos(p * 0.27) * 0.18 + let cos2 = cos(p * 0.41 + 1.7) * 0.16 + let sin1 = sin(p * 0.33 + 0.4) * 0.18 + let sin2 = sin(p * 0.51 + 2.1) * 0.14 + return [ + SIMD2(0.0, 0.0), // top-left (fixed) + SIMD2(0.5 + cos1, 0.0), // top-mid + SIMD2(1.0, 0.0), // top-right (fixed) + SIMD2(0.0, 0.5 + sin1), // left-mid + SIMD2(0.5 + cos2, 0.5 + sin2), // center + SIMD2(1.0, 0.5 + cos1 * 0.7), // right-mid + SIMD2(0.0, 1.0), // bottom-left (fixed) + SIMD2(0.5 + sin2, 1.0), // bottom-mid + SIMD2(1.0, 1.0) // bottom-right (fixed) + ] + } + + /// Slowly cycle each color's hue by a tiny amount so the gradient looks + /// alive without ever crossing into "screensaver" territory. + static func animatedColors(base: [Color], phase: Double) -> [Color] { + base.enumerated().map { index, color in + let shift = cos(phase + Double(index) * 0.31) * 0.025 + return color.shiftedHue(by: shift) + } + } +} + +// MARK: - Palettes + +/// Named color sets. Each is 9 colors arranged to read as a smooth +/// "calm cloud" — low saturation, low brightness, no obvious focal point. +struct AnimatedMeshPalette: Sendable { + var colors: [Color] + + /// Deep indigo / amber / teal. Default — works for the dark home screen. + static let aurora = AnimatedMeshPalette(colors: [ + Color(red: 0.06, green: 0.07, blue: 0.13), // top-left + Color(red: 0.10, green: 0.08, blue: 0.20), // top-mid + Color(red: 0.05, green: 0.10, blue: 0.18), // top-right + Color(red: 0.12, green: 0.09, blue: 0.16), // left-mid + Color(red: 0.18, green: 0.11, blue: 0.22), // center ← deepest violet + Color(red: 0.08, green: 0.12, blue: 0.20), // right-mid + Color(red: 0.16, green: 0.10, blue: 0.10), // bottom-left (amber hint) + Color(red: 0.10, green: 0.08, blue: 0.18), // bottom-mid + Color(red: 0.05, green: 0.09, blue: 0.14) // bottom-right + ]) + + /// Cooler / more "Apple" feel — slate blue, steel, ice. + static let polar = AnimatedMeshPalette(colors: [ + Color(red: 0.05, green: 0.07, blue: 0.10), + Color(red: 0.09, green: 0.12, blue: 0.18), + Color(red: 0.07, green: 0.09, blue: 0.14), + Color(red: 0.10, green: 0.13, blue: 0.18), + Color(red: 0.13, green: 0.16, blue: 0.22), + Color(red: 0.08, green: 0.10, blue: 0.16), + Color(red: 0.06, green: 0.10, blue: 0.16), + Color(red: 0.09, green: 0.11, blue: 0.18), + Color(red: 0.05, green: 0.07, blue: 0.12) + ]) + + /// Warm "candlelight" — fits evening writing session vibes. + static let ember = AnimatedMeshPalette(colors: [ + Color(red: 0.08, green: 0.06, blue: 0.05), + Color(red: 0.14, green: 0.09, blue: 0.06), + Color(red: 0.10, green: 0.07, blue: 0.06), + Color(red: 0.16, green: 0.10, blue: 0.07), + Color(red: 0.22, green: 0.13, blue: 0.08), + Color(red: 0.12, green: 0.08, blue: 0.07), + Color(red: 0.10, green: 0.06, blue: 0.05), + Color(red: 0.15, green: 0.09, blue: 0.06), + Color(red: 0.08, green: 0.06, blue: 0.05) + ]) +} + +// MARK: - Helpers + +private extension Color { + /// Shift the color's hue by a small amount (-1.0 ... 1.0 wraps the wheel). + /// Used purely for subtle gradient breathing — keep `amount` ≤ 0.05. + func shiftedHue(by amount: Double) -> Color { + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + var alpha: CGFloat = 0 + UIColor(self).getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + hue += CGFloat(amount) + hue = hue.truncatingRemainder(dividingBy: 1.0) + if hue < 0 { hue += 1 } + return Color(hue: Double(hue), saturation: Double(saturation), brightness: Double(brightness), opacity: Double(alpha)) + } +} + +// MARK: - Preview + +#Preview("Aurora (dark)") { + ZStack { + OSGAnimatedMeshBackground(palette: .aurora) + Text("aurora") + .font(.largeTitle.weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + .ignoresSafeArea() + .preferredColorScheme(.dark) +} + +#Preview("Polar (dark)") { + ZStack { + OSGAnimatedMeshBackground(palette: .polar) + Text("polar") + .font(.largeTitle.weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + .ignoresSafeArea() + .preferredColorScheme(.dark) +} + +#Preview("Ember (dark)") { + ZStack { + OSGAnimatedMeshBackground(palette: .ember) + Text("ember") + .font(.largeTitle.weight(.semibold)) + .foregroundStyle(.white.opacity(0.85)) + } + .ignoresSafeArea() + .preferredColorScheme(.dark) +} diff --git a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift index 96d35e4..5e952e9 100644 --- a/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift +++ b/OSGKeyboard/Views/Components/PrivacyDisclosureViews.swift @@ -26,9 +26,5 @@ struct PrivacyInfoCard: View { .padding(Spacing.md) .frame(maxWidth: .infinity, alignment: .leading) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } } diff --git a/OSGKeyboard/Views/Components/TabBarVisibility.swift b/OSGKeyboard/Views/Components/TabBarVisibility.swift index 2e49648..dcf6cc5 100644 --- a/OSGKeyboard/Views/Components/TabBarVisibility.swift +++ b/OSGKeyboard/Views/Components/TabBarVisibility.swift @@ -1,18 +1,37 @@ // TabBarVisibility.swift // OSGKeyboard · Main App // -// Push 进 NavigationStack 子页时隐藏底部系统 tab 栏。 -// 子页用 `hidesTabBarWhenPushed()` 声明,根页面保持原生 TabView 导航可见。 +// NavigationStack 根路径统一驱动底部系统 tab 栏显隐。 +// 显隐状态在 push / pop 开始时更新,使原生 Dock 与页面过渡同步。 import OSGKeyboardShared import SwiftUI // MARK: - Modifiers +private struct NavigationStackTabBarVisibilityModifier: ViewModifier { + let isRoot: Bool + + @State private var visibility: Visibility = .visible + + func body(content: Content) -> some View { + content + .toolbarVisibility(visibility, for: .tabBar) + .onAppear { + visibility = isRoot ? .visible : .hidden + } + .onChange(of: isRoot) { _, newValue in + withAnimation(Motion.soft) { + visibility = newValue ? .visible : .hidden + } + } + } +} + extension View { - /// Marks this view as a pushed detail screen so the native tab bar hides. - func hidesTabBarWhenPushed() -> some View { - toolbar(.hidden, for: .tabBar) + /// Keeps the native tab dock visible only at a NavigationStack root. + func navigationStackTabBarVisibility(isRoot: Bool) -> some View { + modifier(NavigationStackTabBarVisibilityModifier(isRoot: isRoot)) } /// Adds ordinary footer breathing room; native TabView owns tab-bar clearance. diff --git a/OSGKeyboard/Views/Components/WideLayoutComponents.swift b/OSGKeyboard/Views/Components/WideLayoutComponents.swift index 85466cd..04c22f2 100644 --- a/OSGKeyboard/Views/Components/WideLayoutComponents.swift +++ b/OSGKeyboard/Views/Components/WideLayoutComponents.swift @@ -37,8 +37,5 @@ struct WideCard: View { content() .padding(padding) .background(palette.surface, in: shape) - .overlay( - shape.stroke(palette.divider, lineWidth: 0.5) - ) } } diff --git a/OSGKeyboard/Views/HelpFeedbackView.swift b/OSGKeyboard/Views/HelpFeedbackView.swift index e3f2006..d079b7e 100644 --- a/OSGKeyboard/Views/HelpFeedbackView.swift +++ b/OSGKeyboard/Views/HelpFeedbackView.swift @@ -31,6 +31,5 @@ struct HelpFeedbackView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.link.support") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } diff --git a/OSGKeyboard/Views/HistoryView.swift b/OSGKeyboard/Views/HistoryView.swift index 2cbb151..08a2dd1 100644 --- a/OSGKeyboard/Views/HistoryView.swift +++ b/OSGKeyboard/Views/HistoryView.swift @@ -39,7 +39,6 @@ struct HistoryView: View { .background(palette.background) .navigationTitle("history.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .toolbar { if !store.entries.isEmpty { ToolbarItem(placement: .topBarTrailing) { @@ -105,7 +104,7 @@ struct HistoryView: View { } } .listStyle(.insetGrouped) - .listSectionSpacing(Spacing.lg) + .listSectionSpacing(CardLayoutMetrics.sectionSpacing) .scrollContentBackground(.hidden) .background(palette.background) .contentMargins(.top, Spacing.md, for: .scrollContent) diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index 2b98cd5..88fe8c8 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -1,10 +1,11 @@ // HomeView.swift // OSGKeyboard · Main App // -// Home: logo, transient Flow connection status, usage stats, history + -// dictionary entry card. History/dictionary -// open via push (system back) rather than bottom-tab destinations. +// Home: logo, transient Flow connection status, usage stats, account rewards, +// and dictionary suggestions. History/dictionary/account destinations open +// via push (system back) rather than bottom-tab destinations. +import OSGKeyboardHostSupport import OSGKeyboardShared import SwiftUI import UIKit @@ -12,6 +13,7 @@ import UIKit private enum HomeRoute: Hashable { case history case dictionary + case account } enum FlowHomePiPStatusDescriptor: Equatable { @@ -97,22 +99,44 @@ enum FlowHomePiPStatusPolicy { } } +enum HomeServiceSetupPolicy { + static func apiKeyMessageKey( + isLocalEngine: Bool, + isASRConfigured: Bool + ) -> String { + if isLocalEngine || isASRConfigured { + return "home.setup.polishKeyMissing" + } + return "home.setup.cloudIncomplete" + } + + static func apiKeyDeepLink( + isLocalEngine: Bool, + isASRConfigured: Bool + ) -> SettingsDeepLink { + if isLocalEngine || isASRConfigured { + return .textPolish + } + return .speechRecognition + } +} + struct HomeView: View { @Environment(\.themePalette) private var palette: ThemePalette @Environment(\.scenePhase) private var scenePhase @Environment(\.horizontalSizeClass) private var horizontalSizeClass @ObservedObject private var config = ProviderConfig.shared - @ObservedObject private var speechHistory = SpeechHistoryStore.shared @EnvironmentObject private var flowManager: FlowSessionManager @State private var micStatus = AppPermissions.micStatus @State private var speechStatus = AppPermissions.speechStatus @State private var path = NavigationPath() - @State private var dictionarySuggestions: [RimeFrequentTerm] = [] - @State private var pendingDictionarySuggestion: RimeFrequentTerm? + @State private var dictionarySuggestions: [FrequentTerm] = [] + @State private var pendingDictionarySuggestion: FrequentTerm? private let appGroupStore = AppGroupStore() - private let rimeFrequentTermStore = RimeFrequentTermStore() + private let dictionaryEntryService = PersonalDictionaryEntryService() + private let frequentTermStore = FrequentTermStore() private var usesWideLayout: Bool { horizontalSizeClass == .regular @@ -130,6 +154,22 @@ struct HomeView: View { return !config.isConfigured } + private var apiKeySetupMessageKey: LocalizedStringKey { + LocalizedStringKey( + HomeServiceSetupPolicy.apiKeyMessageKey( + isLocalEngine: config.isLocalEngine, + isASRConfigured: config.isASRConfigured + ) + ) + } + + private var apiKeySettingsDeepLink: SettingsDeepLink { + HomeServiceSetupPolicy.apiKeyDeepLink( + isLocalEngine: config.isLocalEngine, + isASRConfigured: config.isASRConfigured + ) + } + private var needsPermissionSetup: Bool { micStatus != .granted || speechStatus != .granted } @@ -188,9 +228,12 @@ struct HomeView: View { HistoryView() case .dictionary: PersonalDictionaryView() + case .account: + AccountCenterView() } } } + .navigationStackTabBarVisibility(isRoot: path.isEmpty) .onAppear { refreshPermissionStatuses() refreshDictionarySuggestions() @@ -211,32 +254,6 @@ struct HomeView: View { guard count == 0 else { return } refreshDictionarySuggestions() } - .alert( - pendingDictionarySuggestion.map { - AppL10n.format("home.card.dictionary.confirm.title", $0.term) - } ?? "", - isPresented: Binding( - get: { pendingDictionarySuggestion != nil }, - set: { isPresented in - if !isPresented { - pendingDictionarySuggestion = nil - } - } - ), - presenting: pendingDictionarySuggestion - ) { suggestion in - Button("home.card.dictionary.confirm.add") { - addSuggestedTerm(suggestion) - } - Button("common.cancel", role: .cancel) {} - } message: { suggestion in - Text( - AppL10n.format( - "home.card.dictionary.confirm.message", - suggestion.commitCount - ) - ) - } } // MARK: - Phone layout @@ -247,7 +264,6 @@ struct HomeView: View { let isCompact = geo.size.height < 700 let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl - let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg ZStack(alignment: .top) { sessionHeaderGradient(height: gradientHeight) @@ -265,14 +281,10 @@ struct HomeView: View { if showsFlowConnectionCard { scrollStatusFooter .padding(.horizontal, Spacing.lg) - .padding(.bottom, extrasBottomPadding) + .padding(.bottom, CardLayoutMetrics.sectionSpacing) } - HomeUsageStatsSection(layout: .stacked, compact: isCompact) - .padding(.horizontal, Spacing.lg) - .padding(.bottom, Spacing.md) - - homeLibrarySection + homeContentSections(layout: .stacked, compact: isCompact) .padding(.horizontal, Spacing.lg) .padding(.bottom, Spacing.xl) } @@ -288,6 +300,9 @@ struct HomeView: View { private var scrollStatusFooter: some View { setupGuidanceCard { flowStatusFooter + if needsAPIKeySetup { + apiKeySetupGuidance + } } } @@ -295,16 +310,14 @@ struct HomeView: View { private var wideBody: some View { ScrollView { - VStack(alignment: .leading, spacing: Spacing.lg) { + VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) { wideHeroHeader if showsFlowConnectionCard { scrollStatusFooter } - HomeUsageStatsSection(layout: .split) - - homeLibrarySection + homeContentSections(layout: .split) } .padding(.horizontal, WideLayoutMetrics.pageHorizontalInset) .padding(.top, Spacing.sm) @@ -329,47 +342,38 @@ struct HomeView: View { .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - History / dictionary cards + // MARK: - Usage, account, and dictionary - /// Two independent cards; each header is an accent icon + uppercase label - /// and the body grows with its rows. - private var homeLibrarySection: some View { - VStack(spacing: Spacing.md) { - dictionaryCard - historyCard - } - } - - private var historyCard: some View { - let entries = Array(speechHistory.entries.prefix(Self.libraryPreviewLimit)) - return homeLibraryCard( - titleKey: "history.title", - systemImage: "clock.arrow.circlepath", - route: .history - ) { - if entries.isEmpty { - libraryEmptyLine("home.card.history.empty") - } else { - VStack(spacing: 0) { - ForEach(Array(entries.enumerated()), id: \.element.id) { index, entry in - if index > 0 { libraryRowDivider } - HStack(alignment: .firstTextBaseline, spacing: Spacing.sm) { - Text(entry.text) - .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - Spacer(minLength: Spacing.xs) - Text(Self.previewTimeFormatter.string(from: entry.createdAt)) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .monospacedDigit() - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, Spacing.sm) - } - } + /// Keep the Home information hierarchy stable on both phone and iPad: + /// account rewards → four metrics → calendar → personal dictionary. + private func homeContentSections( + layout: UsageStatsClusterLayout, + compact: Bool = false + ) -> some View { + VStack(spacing: CardLayoutMetrics.sectionSpacing) { + AccountRewardsCard { + path.append(HomeRoute.account) } + + HomeUsageStatsSection( + layout: layout, + compact: compact, + content: .metrics, + onOpenHistory: { + path.append(HomeRoute.history) + }, + onOpenDictionary: { + path.append(HomeRoute.dictionary) + } + ) + + HomeUsageStatsSection( + layout: layout, + compact: compact, + content: .calendar + ) + + dictionaryCard } } @@ -381,8 +385,8 @@ struct HomeView: View { HStack(spacing: Spacing.xs) { Image(systemName: "sparkles") .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) + .foregroundStyle(palette.textPrimary) + .symbolRenderingMode(.monochrome) Text("settings.personalDictionary.title") .font(.system(size: 13, weight: .semibold)) .tracking(0.6) @@ -398,50 +402,71 @@ struct HomeView: View { .buttonStyle(.plain) if dictionarySuggestions.isEmpty { - libraryEmptyLine("home.card.dictionary.smart.empty") + Text("home.card.dictionary.smart.empty") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textTertiary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: 54, alignment: .center) } else { - LazyVGrid( - columns: [ - GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm) - ], - alignment: .leading, - spacing: Spacing.sm - ) { - ForEach(dictionarySuggestions) { suggestion in - Button { - pendingDictionarySuggestion = suggestion - } label: { - Text(suggestion.term) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(palette.accent) - .lineLimit(1) - .minimumScaleFactor(0.8) - .padding(.horizontal, Spacing.sm) - .frame(maxWidth: .infinity, minHeight: 38) - .background( - LinearGradient( - colors: [ - palette.accent.opacity(0.16), - palette.accent.opacity(0.07) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - in: Capsule() - ) - .overlay { - Capsule() - .stroke(palette.accent.opacity(0.22), lineWidth: 0.5) - } - } - .buttonStyle(.plain) - .accessibilityHint( - AppL10n.format( - "home.card.dictionary.smart.accessibilityHint", - suggestion.commitCount + VStack(spacing: Spacing.sm) { + LazyVGrid( + columns: [ + GridItem( + .adaptive(minimum: 88, maximum: 160), + spacing: CardLayoutMetrics.compactItemSpacing ) - ) + ], + alignment: .leading, + spacing: CardLayoutMetrics.compactItemSpacing + ) { + ForEach(dictionarySuggestions) { suggestion in + Button { + pendingDictionarySuggestion = suggestion + } label: { + Text(suggestion.term) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.8) + .padding(.horizontal, Spacing.sm) + .frame(maxWidth: .infinity, minHeight: 38) + .background( + palette.textPrimary.opacity(0.08), + in: RoundedRectangle(cornerRadius: 10, style: .continuous) + ) + } + .buttonStyle(.plain) + .popover( + isPresented: Binding( + get: { + pendingDictionarySuggestion?.id == suggestion.id + }, + set: { isPresented in + if !isPresented, + pendingDictionarySuggestion?.id == suggestion.id { + pendingDictionarySuggestion = nil + } + } + ), + attachmentAnchor: .rect(.bounds), + arrowEdge: .bottom + ) { + dictionarySuggestionConfirmation(suggestion) + .presentationCompactAdaptation(.popover) + } + .accessibilityHint( + AppL10n.format( + "home.card.dictionary.smart.accessibilityHint", + suggestion.commitCount + ) + ) + } } + + Text("home.card.dictionary.smart.addHint") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .frame(maxWidth: .infinity, alignment: .center) } } } @@ -450,55 +475,39 @@ struct HomeView: View { .surfaceCard() } - /// Card shell: accent icon + label top-left, chevron trailing, custom body. - private func homeLibraryCard( - titleKey: LocalizedStringKey, - systemImage: String, - route: HomeRoute, - @ViewBuilder content: () -> Content - ) -> some View { - Button { - path.append(route) - } label: { - VStack(alignment: .leading, spacing: Spacing.xs) { - HStack(spacing: Spacing.xs) { - Image(systemName: systemImage) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(palette.accent) - .symbolRenderingMode(.hierarchical) - Text(titleKey) - .font(.system(size: 13, weight: .semibold)) - .tracking(0.6) - .textCase(.uppercase) - .foregroundStyle(palette.textTertiary) - Spacer(minLength: Spacing.xs) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - } - content() - } - .padding(Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .surfaceCard() - .accessibilityElement(children: .combine) - } + private func dictionarySuggestionConfirmation(_ suggestion: FrequentTerm) -> some View { + VStack(alignment: .leading, spacing: Spacing.md) { + Text(AppL10n.format("home.card.dictionary.confirm.title", suggestion.term)) + .font(TypeStyle.headline) + .foregroundStyle(palette.textPrimary) - private var libraryRowDivider: some View { - Rectangle() - .fill(palette.divider) - .frame(height: 0.5) - } - - private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View { - Text(key) + Text( + AppL10n.format( + "home.card.dictionary.confirm.message", + suggestion.commitCount + ) + ) .font(TypeStyle.footnote) - .foregroundStyle(palette.textTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, Spacing.xs) + .foregroundStyle(palette.textSecondary) + + HStack(spacing: Spacing.sm) { + Button("common.cancel") { + pendingDictionarySuggestion = nil + } + .buttonStyle(.bordered) + + Spacer(minLength: 0) + + Button("home.card.dictionary.confirm.add") { + addSuggestedTerm(suggestion) + } + .buttonStyle(.borderedProminent) + .tint(palette.accent) + } + } + .padding(Spacing.lg) + .frame(width: 300) + .background(palette.surface) } private func refreshPermissionStatuses() { @@ -508,32 +517,29 @@ struct HomeView: View { private func refreshDictionarySuggestions() { let dictionary = appGroupStore.personalDictionary - let suggestions = rimeFrequentTermStore.suggestions( + let suggestions = frequentTermStore.suggestions( excludingPersonalTerms: Set(dictionary.entries.map(\.term)), limit: Self.dictionarySuggestionLimit ) - dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount - ? suggestions + let visibleSuggestions = Array(suggestions.prefix(Self.dictionarySuggestionLimit)) + dictionarySuggestions = visibleSuggestions.count >= Self.minimumDictionarySuggestionCount + ? visibleSuggestions : [] } - private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) { - var dictionary = appGroupStore.personalDictionary - guard let saved = dictionary.upsertManual(term: suggestion.term), - let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else { + private func addSuggestedTerm(_ suggestion: FrequentTerm) { + guard let saved = dictionaryEntryService.saveEntry( + term: suggestion.term, + source: .history, + minimumUsageCount: suggestion.commitCount + ) else { return } - dictionary.entries[index].usageCount = max( - dictionary.entries[index].usageCount, - suggestion.commitCount - ) - dictionary.version += 1 - appGroupStore.setPersonalDictionary(dictionary) pendingDictionarySuggestion = nil refreshDictionarySuggestions() Task { - try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary) + await dictionaryEntryService.finishSaving(saved) } } @@ -548,6 +554,11 @@ struct HomeView: View { } } + private func openSettings(_ deepLink: SettingsDeepLink) { + SettingsDeepLink.setPending(deepLink) + NotificationCenter.default.post(name: .osgOpenSettingsDeepLink, object: nil) + } + // MARK: - Top gradient private func sessionHeaderGradient(height: CGFloat) -> some View { @@ -651,6 +662,8 @@ struct HomeView: View { .buttonStyle(.plain) .padding(.leading, Spacing.xs) } else if needsAPIKeySetup { + // The explanatory copy and both setup paths sit below this + // compact status row so they remain readable on narrow phones. EmptyView() } else if canRetryPiP { Button { @@ -701,6 +714,51 @@ struct HomeView: View { .animation(Motion.soft, value: flowManager.isActive) } + private var apiKeySetupGuidance: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + Text(apiKeySetupMessageKey) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + Text("home.setup.credits.alternative") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + + HStack(spacing: Spacing.sm) { + Button { + openSettings(apiKeySettingsDeepLink) + } label: { + Label("home.setup.byok.configure", systemImage: "key") + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: 34) + .background( + palette.surfaceElevated, + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) + } + .buttonStyle(.plain) + + Button { + openSettings(.aiService) + } label: { + Label("home.setup.credits.open", systemImage: "sparkles") + .foregroundStyle(palette.textOnAccent) + .padding(.horizontal, Spacing.sm) + .frame(minHeight: 34) + .background( + palette.accent, + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + ) + } + .buttonStyle(.plain) + } + .font(TypeStyle.body) + } + } + private func setupGuidanceCard(@ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: Spacing.sm) { content() @@ -708,10 +766,6 @@ struct HomeView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } private var flowStatusColor: Color { @@ -750,15 +804,6 @@ struct HomeView: View { } } - /// Rows shown inside the history / dictionary preview cards. - private static let libraryPreviewLimit = 3 private static let minimumDictionarySuggestionCount = 3 - private static let dictionarySuggestionLimit = 5 - - private static let previewTimeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .none - formatter.timeStyle = .short - return formatter - }() + private static let dictionarySuggestionLimit = 6 } diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift index d96393e..848d865 100644 --- a/OSGKeyboard/Views/KeyboardPreviewSheet.swift +++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift @@ -97,10 +97,6 @@ struct KeyboardPreviewSheet: View { } .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium)) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium) - .stroke(palette.divider, lineWidth: 0.5) - ) } private var controls: some View { diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift index b9f1099..498a85b 100644 --- a/OSGKeyboard/Views/KeyboardPreviewStub.swift +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -66,7 +66,6 @@ struct KeyboardPreviewStub: View { .foregroundStyle(palette.textSecondary) .frame(width: 28, height: 28) .background(palette.surface, in: Circle()) - .overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) } @@ -84,7 +83,6 @@ struct KeyboardPreviewStub: View { .foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) .background(palette.surfaceElevated, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) .accessibilityLabel(Text("preview.modeChip.cycle")) @@ -101,7 +99,6 @@ struct KeyboardPreviewStub: View { .foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) .background(palette.surfaceElevated, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) .accessibilityLabel(Text("preview.localeChip.cycle")) diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 0ec2924..0b24a8e 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -201,6 +201,12 @@ struct MainAppRoot: View { return } refreshOfficialSkillCatalog(reason: "scenePhase.active") + Task { + // A protected Keychain can be temporarily unavailable during + // launch. The coordinator no-ops after a terminal restore, so + // this only retries a previously interrupted attempt. + await accountSession.restoreIfNeeded() + } if config.hasCompletedOnboarding { activateForegroundServices(reason: "scenePhase.active") AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active") diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift index 340162d..824114a 100644 --- a/OSGKeyboard/Views/MainTabView.swift +++ b/OSGKeyboard/Views/MainTabView.swift @@ -15,14 +15,18 @@ struct MainTabView: View { } var body: some View { - Group { - if usesSplitLayout { - MainSplitView(selection: $tab) - } else { - phoneTabLayout + ZStack { + palette.background + .ignoresSafeArea() + + Group { + if usesSplitLayout { + MainSplitView(selection: $tab) + } else { + phoneTabLayout + } } } - .background(palette.background) .ignoresSafeArea(.keyboard, edges: .bottom) .onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in tab = .settings diff --git a/OSGKeyboard/Views/OSGAnimatedBackgroundPreviewView.swift b/OSGKeyboard/Views/OSGAnimatedBackgroundPreviewView.swift new file mode 100644 index 0000000..562779a --- /dev/null +++ b/OSGKeyboard/Views/OSGAnimatedBackgroundPreviewView.swift @@ -0,0 +1,83 @@ +// OSGAnimatedBackgroundPreviewView.swift +// OSGKeyboard · Main App (DEBUG-only) +// +// Standalone preview host for `OSGAnimatedMeshBackground`. +// Open from anywhere with `OSGAnimatedBackgroundPreviewView()`. +// +// Tapping the screen swaps the palette; long-press pauses/resumes the animation +// so you can capture a still frame for App Store screenshots. + +#if DEBUG +import SwiftUI + +struct OSGAnimatedBackgroundPreviewView: View { + private enum PaletteChoice: String, CaseIterable, Identifiable { + case aurora, polar, ember + var id: String { rawValue } + var label: String { + switch self { + case .aurora: return "Aurora" + case .polar: return "Polar" + case .ember: return "Ember" + } + } + var palette: AnimatedMeshPalette { + switch self { + case .aurora: return .aurora + case .polar: return .polar + case .ember: return .ember + } + } + } + + @State private var choice: PaletteChoice = .aurora + @State private var paused: Bool = false + + var body: some View { + ZStack { + OSGAnimatedMeshBackground(palette: choice.palette, paused: paused) + .ignoresSafeArea() + + VStack(spacing: 16) { + Spacer() + Text(choice.label) + .font(.system(size: 56, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.92)) + Text(paused ? "paused — long-press to resume" : "tap to switch · long-press to pause") + .font(.callout) + .foregroundStyle(.white.opacity(0.55)) + Spacer().frame(height: 12) + paletteDots + .padding(.bottom, 32) + } + } + .background(Color.black) + .preferredColorScheme(.dark) + .contentShape(Rectangle()) + .onTapGesture { + withAnimation(.easeInOut(duration: 0.4)) { + let all = PaletteChoice.allCases + let next = all.firstIndex(of: choice).map { all[($0 + 1) % all.count] } ?? choice + choice = next + } + } + .onLongPressGesture(minimumDuration: 0.5) { + paused.toggle() + } + } + + private var paletteDots: some View { + HStack(spacing: 10) { + ForEach(PaletteChoice.allCases) { item in + Circle() + .fill(item == choice ? Color.white : Color.white.opacity(0.3)) + .frame(width: 8, height: 8) + } + } + } +} + +#Preview("Aurora · polar · ember") { + OSGAnimatedBackgroundPreviewView() +} +#endif diff --git a/OSGKeyboard/Views/OnboardingExperienceView.swift b/OSGKeyboard/Views/OnboardingExperienceView.swift index 1ca74e0..16bb0e2 100644 --- a/OSGKeyboard/Views/OnboardingExperienceView.swift +++ b/OSGKeyboard/Views/OnboardingExperienceView.swift @@ -334,10 +334,6 @@ struct OnboardingExperienceView: View { } .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } // MARK: - Permissions @@ -416,10 +412,6 @@ struct OnboardingExperienceView: View { } .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } // MARK: - Keyboard setup @@ -489,10 +481,6 @@ struct OnboardingExperienceView: View { palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) .padding(.top, Spacing.xxl) TextField( @@ -762,10 +750,6 @@ struct OnboardingExperienceView: View { } .frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(keyboardReady ? palette.accent.opacity(0.42) : palette.divider, lineWidth: 1) - ) } private var practiceVoiceSample: some View { @@ -822,10 +806,6 @@ struct OnboardingExperienceView: View { } .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } private var practiceTitle: LocalizedStringKey { @@ -918,7 +898,7 @@ struct OnboardingExperienceView: View { .font(TypeStyle.footnote) .foregroundStyle(palette.textSecondary) .opacity(accountSession.isSignedIn ? 0 : 1) - .disabled(accountSession.isSignedIn) + .disabled(accountSession.isSignedIn || accountSession.operation != nil) .accessibilityHidden(accountSession.isSignedIn) } .frame(maxWidth: .infinity) @@ -927,10 +907,6 @@ struct OnboardingExperienceView: View { palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) .padding(.top, Spacing.xxl) .accessibilityIdentifier("onboarding.loginReward.card") } @@ -1483,10 +1459,6 @@ private extension View { palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } } @@ -1584,9 +1556,5 @@ private struct KeyboardSettingsPreview: View { .padding(Spacing.md) } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.dividerStrong, lineWidth: 0.5) - ) } } diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index d545d1a..4ef8b62 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -295,10 +295,6 @@ struct OnboardingView: View { .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.dividerStrong, lineWidth: 0.5) - ) .foregroundStyle(palette.textPrimary) } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/OpenSourceLicensesView.swift b/OSGKeyboard/Views/OpenSourceLicensesView.swift index 8990a1e..6911985 100644 --- a/OSGKeyboard/Views/OpenSourceLicensesView.swift +++ b/OSGKeyboard/Views/OpenSourceLicensesView.swift @@ -42,7 +42,6 @@ struct OpenSourceLicensesView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.licenses.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View { @@ -105,6 +104,5 @@ private struct OpenSourceLicenseDetailView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle(entry.name) .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } diff --git a/OSGKeyboard/Views/PersonalDictionaryView.swift b/OSGKeyboard/Views/PersonalDictionaryView.swift index c87be43..6484718 100644 --- a/OSGKeyboard/Views/PersonalDictionaryView.swift +++ b/OSGKeyboard/Views/PersonalDictionaryView.swift @@ -22,7 +22,7 @@ struct PersonalDictionaryView: View { @State private var generatingAliasEntryIDs: Set = [] private let store = AppGroupStore() - private let aliasGenerator = DictionaryAliasGenerator() + private let dictionaryEntryService = PersonalDictionaryEntryService() var body: some View { ZStack { @@ -37,7 +37,6 @@ struct PersonalDictionaryView: View { .background(palette.background) .navigationTitle("settings.personalDictionary.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .toolbar { if !dictionary.entries.isEmpty { ToolbarItem(placement: .topBarTrailing) { @@ -76,7 +75,11 @@ struct PersonalDictionaryView: View { initialTerm: editingEntry?.term ?? "", isEditing: editingEntry != nil ) { term in - saveManualEntry(term: term, editingID: editingEntry?.id) + saveEntry( + term: term, + editingID: editingEntry?.id, + source: editingEntry?.source ?? .manual + ) } } .task { @@ -110,7 +113,7 @@ struct PersonalDictionaryView: View { } } .listStyle(.insetGrouped) - .listSectionSpacing(Spacing.lg) + .listSectionSpacing(CardLayoutMetrics.sectionSpacing) .scrollContentBackground(.hidden) .background(palette.background) // 让搜索栏与首个词条之间留出呼吸空间,视觉更透气。 @@ -138,14 +141,6 @@ struct PersonalDictionaryView: View { Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)) .font(TypeStyle.caption2) .foregroundStyle(palette.textTertiary) - if entry.usageCount > 1 { - Text("·") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - Text("settings.personalDictionary.usageCount \(entry.usageCount)") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - } if generatingAliasEntryIDs.contains(entry.id) { Text("·") .font(TypeStyle.caption2) @@ -231,32 +226,25 @@ struct PersonalDictionaryView: View { // MARK: - Mutations - private func saveManualEntry(term: String, editingID: UUID?) { - let previousTerm = editingID.flatMap { id in - dictionary.entries.first(where: { $0.id == id })?.term + private func saveEntry( + term: String, + editingID: UUID?, + source: PersonalDictionary.Entry.Source + ) { + guard let saved = dictionaryEntryService.saveEntry( + term: term, + existingID: editingID, + source: source + ) else { + return } - let termChanged = previousTerm.map { - $0.caseInsensitiveCompare(term) != .orderedSame - } ?? true - - guard dictionary.upsertManual(term: term, existingID: editingID) != nil else { return } - persist() - - guard let saved = dictionary.entry(matchingTerm: term) else { return } - let shouldGenerate = saved.source == .manual && (editingID == nil || termChanged) - if shouldGenerate { - generateAliases(for: saved.id, term: saved.term) + dictionary = saved.dictionary + if saved.shouldGenerateAliases { + generatingAliasEntryIDs.insert(saved.entryID) } - } - - private func generateAliases(for entryID: UUID, term: String) { - generatingAliasEntryIDs.insert(entryID) Task { - let aliases = await aliasGenerator.generateAliases(for: term) - generatingAliasEntryIDs.remove(entryID) - guard !aliases.isEmpty else { return } - dictionary.updateAliases(for: entryID, aliases: aliases) - persist() + dictionary = await dictionaryEntryService.finishSaving(saved) + generatingAliasEntryIDs.remove(saved.entryID) } } diff --git a/OSGKeyboard/Views/PolishStylesView.swift b/OSGKeyboard/Views/PolishStylesView.swift index 46d1b30..728a124 100644 --- a/OSGKeyboard/Views/PolishStylesView.swift +++ b/OSGKeyboard/Views/PolishStylesView.swift @@ -24,14 +24,14 @@ struct PolishStylesView: View { private let store = AppGroupStore() private let columns = [ - GridItem(.flexible(), spacing: Spacing.sm), - GridItem(.flexible(), spacing: Spacing.sm) + GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing), + GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing) ] var body: some View { NavigationStack { ScrollView { - CardPageContent(spacing: Spacing.xl) { + CardPageContent { styleLearningCard packGridSection( title: "polishStyles.builtin.section", @@ -114,12 +114,12 @@ struct PolishStylesView: View { return VStack(alignment: .leading, spacing: Spacing.md) { HStack(alignment: .top, spacing: Spacing.md) { - Image(systemName: "waveform") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(palette.accent) - .frame(width: 42, height: 42) + Image(systemName: "brain") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + .frame(width: 38, height: 38) .background( - palette.accentMuted, + palette.textPrimary.opacity(0.08), in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) ) @@ -191,13 +191,6 @@ struct PolishStylesView: View { isActionAvailable ? palette.accent : palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke( - isActionAvailable ? Color.clear : palette.dividerStrong, - lineWidth: 0.5 - ) - ) .contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) } .buttonStyle(.plain) @@ -221,7 +214,7 @@ struct PolishStylesView: View { packs: [PolishStylePack] ) -> some View { CardSection(title) { - LazyVGrid(columns: columns, spacing: Spacing.sm) { + LazyVGrid(columns: columns, spacing: CardLayoutMetrics.compactItemSpacing) { ForEach(packs) { pack in packCard(pack) } @@ -274,13 +267,6 @@ struct PolishStylesView: View { isSelected ? palette.accentMuted : palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke( - isSelected ? palette.accent : palette.divider, - lineWidth: isSelected ? 1.5 : 0.5 - ) - ) .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .contextMenu { Button("polishStyles.duplicate") { diff --git a/OSGKeyboard/Views/PrivacyPolicyView.swift b/OSGKeyboard/Views/PrivacyPolicyView.swift index 93f13af..7166fe8 100644 --- a/OSGKeyboard/Views/PrivacyPolicyView.swift +++ b/OSGKeyboard/Views/PrivacyPolicyView.swift @@ -18,7 +18,6 @@ struct PrivacyPolicyView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.privacy.policy") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } private var privacyScrollAnchor: String? { diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 9bb5654..6219f44 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -30,28 +30,43 @@ struct ProviderPickerSection: View { var body: some View { // 只有右侧芯片是 Menu 的 label;标题留在行外,避免菜单弹出时 // 把整行 label 一起隐藏,导致左侧「供应商」文字消失。 - SettingsProviderRow(title: AppL10n.string("settings.provider.supplier")) { + HStack(spacing: Spacing.lg) { + Text(AppL10n.string("settings.provider.supplier")) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + .fixedSize(horizontal: true, vertical: false) + + Spacer(minLength: Spacing.sm) + Menu { - ForEach(visiblePresets, id: \.id) { provider in - Button { - select(provider) - } label: { - let name = ProviderDisplayName.name(for: provider.id) - if provider.id == selectedProviderId { - Label(name, systemImage: "checkmark") - } else { - Text(name) - } + Picker("", selection: providerSelection) { + ForEach(visiblePresets, id: \.id) { provider in + Text(ProviderDisplayName.name(for: provider.id)) + .tag(provider.id) } } + .labelsHidden() } label: { providerChip } .buttonStyle(.plain) } + .settingsListRow() .surfaceCard(enabled: showsSurface) } + private var providerSelection: Binding { + Binding( + get: { selectedProviderId }, + set: { providerID in + guard let provider = visiblePresets.first(where: { $0.id == providerID }) else { + return + } + select(provider) + } + ) + } + private func select(_ provider: LLMProvider) { withAnimation(Motion.quick) { switch role { @@ -63,14 +78,13 @@ struct ProviderPickerSection: View { } } - /// 收起状态展示:当前所选供应商 logo + 名称 + 展开箭头。 + /// 收起状态展示:当前所选供应商名称、能力标记与展开箭头。 private var providerChip: some View { HStack(spacing: Spacing.sm) { - providerMark(selectedProvider) - Text(ProviderDisplayName.name(for: selectedProvider.id)) .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) + .lineLimit(1) if selectedProvider.supportsPersonalDictionaryCloudASR { personalDictionaryBadge @@ -79,36 +93,14 @@ struct ProviderPickerSection: View { streamingBadge } - Spacer(minLength: Spacing.xs) - Image(systemName: "chevron.up.chevron.down") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(palette.textTertiary) + .accessibilityHidden(true) } - .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } - @ViewBuilder - private func providerMark(_ provider: LLMProvider) -> some View { - ZStack { - Circle() - .fill(palette.accentMuted) - .frame(width: 32, height: 32) - if let asset = ProviderLogo.assetName(for: provider.id) { - Image(asset) - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - .foregroundStyle(palette.accent) - } else { - Text(String(provider.name.prefix(1))) - .font(TypeStyle.caption) - .foregroundStyle(palette.accent) - } - } - } - /// 通义千问 / 智谱 GLM 等支持云端 ASR 热词 API 的提供商。 private var personalDictionaryBadge: some View { Text("settings.provider.personalDictionaryBadge") @@ -117,6 +109,7 @@ struct ProviderPickerSection: View { .padding(.horizontal, Spacing.sm) .padding(.vertical, 4) .background(palette.accentMuted, in: Capsule()) + .lineLimit(1) } /// Bailian / Volcengine / OpenAI Realtime — utterance-level true streaming. @@ -127,5 +120,6 @@ struct ProviderPickerSection: View { .padding(.horizontal, Spacing.sm) .padding(.vertical, 4) .background(palette.accentMuted, in: Capsule()) + .lineLimit(1) } } diff --git a/OSGKeyboard/Views/SettingsICloudSyncRow.swift b/OSGKeyboard/Views/SettingsICloudSyncRow.swift index 614ec60..5a56f42 100644 --- a/OSGKeyboard/Views/SettingsICloudSyncRow.swift +++ b/OSGKeyboard/Views/SettingsICloudSyncRow.swift @@ -21,7 +21,7 @@ struct SettingsICloudSyncRow: View { private let store = AppGroupStore() var body: some View { - VStack(alignment: .leading, spacing: Spacing.xxs) { + VStack(spacing: 0) { Toggle(isOn: toggleBinding) { VStack(alignment: .leading, spacing: Spacing.xxs) { Text("settings.appSettings.iCloudSync.title") @@ -35,43 +35,50 @@ struct SettingsICloudSyncRow: View { } .tint(palette.accent) .disabled(isApplyingToggle) + .settingsListRow(alignment: .leading) if isEnabled { + Divider().background(palette.divider) + Button { syncNow() } label: { - HStack(spacing: Spacing.xs) { - Text(syncButtonTitleKey) - .font(TypeStyle.caption) - Spacer(minLength: 0) - if isSyncingNow { - ProgressView() - .controlSize(.mini) - } else if showSyncedConfirmation { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) + VStack(alignment: .leading, spacing: Spacing.xxs) { + HStack(spacing: Spacing.sm) { + Text(syncButtonTitleKey) + .font(TypeStyle.body) + Spacer(minLength: Spacing.xs) + if isSyncingNow { + ProgressView() + .controlSize(.small) + } else { + Image( + systemName: showSyncedConfirmation + ? "checkmark" + : "arrow.triangle.2.circlepath" + ) + .font(.system(size: 14, weight: .semibold)) + } + } + + if let syncErrorMessage { + Text(syncErrorMessage) + .font(TypeStyle.caption2) + .foregroundStyle(palette.danger) + .fixedSize(horizontal: false, vertical: true) } } // Fill the row so the whole strip is tappable, not just // the caption glyphs (previously easy to miss). .frame(maxWidth: .infinity, alignment: .leading) + .settingsListRow(alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) .foregroundStyle(palette.accent) .disabled(isSyncingNow || isApplyingToggle) - .padding(.top, Spacing.xxs) - } - - if let syncErrorMessage { - Text(syncErrorMessage) - .font(TypeStyle.caption2) - .foregroundStyle(.red) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, Spacing.xxs) } } - .settingsListRow(alignment: .leading) .onAppear { reloadFromStore() } .onReceive( NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) diff --git a/OSGKeyboard/Views/SettingsPreferenceRows.swift b/OSGKeyboard/Views/SettingsPreferenceRows.swift index b38fdcd..4b6d296 100644 --- a/OSGKeyboard/Views/SettingsPreferenceRows.swift +++ b/OSGKeyboard/Views/SettingsPreferenceRows.swift @@ -180,17 +180,13 @@ struct DefaultInputModePickerRow: View { Spacer(minLength: 8) Menu { - ForEach(options, id: \.id) { option in - Button { - selection = DefaultInputMode(rawValue: option.id) ?? .voice - } label: { - if option.id == selection.rawValue { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } + Picker("", selection: defaultInputModeSelection) { + ForEach(options, id: \.id) { option in + Text(option.label) + .tag(option.id) } } + .labelsHidden() } label: { HStack(spacing: 4) { Text(currentLabel) @@ -205,6 +201,13 @@ struct DefaultInputModePickerRow: View { .settingsListRow() } + private var defaultInputModeSelection: Binding { + Binding( + get: { selection.rawValue }, + set: { selection = DefaultInputMode(rawValue: $0) ?? .voice } + ) + } + private var currentLabel: String { options.first(where: { $0.id == selection.rawValue })?.label ?? "—" } @@ -248,17 +251,13 @@ struct SettingsMenuPickerRow: View { .foregroundStyle(palette.textPrimary) Spacer() Menu { - ForEach(options, id: \.id) { o in - Button { - selection = o.id - } label: { - if o.id == selection { - Label(o.label, systemImage: "checkmark") - } else { - Text(o.label) - } + Picker("", selection: $selection) { + ForEach(options, id: \.id) { option in + Text(option.label) + .tag(option.id) } } + .labelsHidden() } label: { HStack(spacing: 4) { Text(currentLabel) @@ -294,22 +293,13 @@ struct LocalePickerRow: View { .foregroundStyle(palette.textPrimary) Spacer() Menu { - ForEach(locales, id: \.id) { locale in - Button { - selection = locale.id - } label: { - // iOS Menu converts SwiftUI Label to UIAction (title + image). - // Using Label keeps checkmark + on-device icon both visible. - let name = label(for: locale.id) - if locale.id == selection { - Label(name, systemImage: "checkmark") - } else if locale.onDevice { - Label(name, systemImage: "iphone") - } else { - Text(name) - } + Picker("", selection: $selection) { + ForEach(locales, id: \.id) { locale in + Text(label(for: locale.id)) + .tag(locale.id) } } + .labelsHidden() } label: { HStack(spacing: 6) { // On-device badge for the currently selected locale. diff --git a/OSGKeyboard/Views/SettingsProviderControls.swift b/OSGKeyboard/Views/SettingsProviderControls.swift index d5751ad..38242c3 100644 --- a/OSGKeyboard/Views/SettingsProviderControls.swift +++ b/OSGKeyboard/Views/SettingsProviderControls.swift @@ -106,10 +106,6 @@ struct SettingsCredentialRow: View { .foregroundStyle(palette.textSecondary) .frame(width: 38, height: 38) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } .buttonStyle(.plain) .accessibilityLabel(label) @@ -184,20 +180,13 @@ struct SettingsModelPickerRow: View { Button(AppL10n.string("settings.provider.modelsEmptyHint")) {} .disabled(true) } else { - ForEach(models, id: \.self) { modelId in - Button { - invalidateRequest() - model = modelId - message = AppL10n.format("settings.provider.modelSelected", modelId) - failed = false - } label: { - if modelId == model { - Label(modelId, systemImage: "checkmark") - } else { - Text(modelId) - } + Picker("", selection: modelSelection) { + ForEach(models, id: \.self) { modelID in + Text(modelID) + .tag(modelID) } } + .labelsHidden() } } label: { Image(systemName: "chevron.up.chevron.down") @@ -211,6 +200,18 @@ struct SettingsModelPickerRow: View { } } + private var modelSelection: Binding { + Binding( + get: { model }, + set: { modelID in + invalidateRequest() + model = modelID + message = AppL10n.format("settings.provider.modelSelected", modelID) + failed = false + } + ) + } + private var refreshButton: some View { Button { runFetchModels() @@ -227,10 +228,6 @@ struct SettingsModelPickerRow: View { } .frame(width: controlHeight, height: controlHeight) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } .buttonStyle(.plain) .disabled(isRunning) @@ -345,10 +342,9 @@ struct SettingsProviderToolsRow: View { .foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.md) .frame(minHeight: 34) - .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium)) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium) - .stroke(palette.divider, lineWidth: 0.5) + .background( + palette.surfaceElevated, + in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) ) } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/SettingsSecondaryPages.swift b/OSGKeyboard/Views/SettingsSecondaryPages.swift index 6c6e106..05dc18b 100644 --- a/OSGKeyboard/Views/SettingsSecondaryPages.swift +++ b/OSGKeyboard/Views/SettingsSecondaryPages.swift @@ -146,18 +146,13 @@ struct AppleASRPickerRow: View { var body: some View { Toggle(isOn: usesAppleRecognition) { - HStack(spacing: Spacing.sm) { - Image(systemName: "apple.logo") - .foregroundStyle(palette.accent) - .frame(width: 28) - VStack(alignment: .leading, spacing: Spacing.xxs) { - Text("settings.asr.apple.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Text("settings.asr.apple.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - } + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("settings.asr.apple.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text("settings.asr.apple.subtitle") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) } } .tint(palette.accent) @@ -213,7 +208,6 @@ struct SpeechRecognitionSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.speechRecognition.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } @@ -238,7 +232,6 @@ struct TextPolishSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.textPolish.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } @@ -322,7 +315,6 @@ struct GeneralSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.general.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } @@ -346,7 +338,6 @@ struct AIAgentSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage)) .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() } } @@ -368,34 +359,34 @@ struct ClipboardSettingsView: View { CardSection("settings.clipboard.section") { VStack(spacing: 0) { Toggle(isOn: $config.clipboardHistoryEnabled) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Spacing.xxs) { Text("settings.clipboard.history.title") + .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Text("settings.clipboard.history.footer") - .font(.footnote) - .foregroundStyle(palette.textSecondary) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) } } .tint(palette.accent) - .padding(.horizontal, 16) - .padding(.vertical, 12) + .settingsListRow() Divider().background(palette.divider) Toggle(isOn: clipboardCandidateBinding) { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Spacing.xxs) { Text("settings.clipboard.candidate.title") + .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Text("settings.clipboard.candidate.footer") - .font(.footnote) - .foregroundStyle(palette.textSecondary) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) } } .tint(palette.accent) .disabled(!config.clipboardHistoryEnabled) .opacity(config.clipboardHistoryEnabled ? 1 : 0.45) - .padding(.horizontal, 16) - .padding(.vertical, 12) + .settingsListRow() } .surfaceCard() } @@ -406,7 +397,7 @@ struct ClipboardSettingsView: View { VStack(spacing: 0) { HStack(alignment: .center, spacing: Spacing.sm) { Text("settings.clipboard.paste.body") - .font(.footnote) + .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) if pasteAccessVerified { @@ -420,8 +411,7 @@ struct ClipboardSettingsView: View { .accessibilityIdentifier("settings.clipboard.paste.verified") } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .settingsListRow() if !pasteAccessVerified { Divider().background(palette.divider) @@ -463,24 +453,25 @@ struct ClipboardSettingsView: View { CardSection("settings.clipboard.storage.section") { VStack(spacing: 0) { Text("settings.clipboard.storage.body") - .font(.footnote) + .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 12) + .settingsListRow() Divider().background(palette.divider) Button(role: .destructive) { showClearConfirmation = true } label: { - HStack { + HStack(spacing: Spacing.sm) { Text("settings.clipboard.clear.button") - Spacer() + .font(TypeStyle.body) + Spacer(minLength: Spacing.xs) Image(systemName: "trash") + .font(.system(size: 14, weight: .semibold)) } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .foregroundStyle(palette.danger) + .settingsListRow() .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -494,7 +485,6 @@ struct ClipboardSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage)) .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .confirmationDialog( "settings.clipboard.clear.title", isPresented: $showClearConfirmation, @@ -574,12 +564,15 @@ struct AboutSettingsView: View { CardSection("settings.analytics.section") { VStack(alignment: .leading, spacing: 0) { Toggle( - "settings.analytics.title", isOn: Binding( get: { analytics.isEnabled }, set: { analytics.setEnabled($0) } ) - ) + ) { + Text("settings.analytics.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } .tint(palette.accent) .settingsListRow() @@ -660,7 +653,6 @@ struct AboutSettingsView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.about.title") .navigationBarTitleDisplayMode(.inline) - .hidesTabBarWhenPushed() .onAppear { analytics.refreshEnabledState() } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 7d440fe..f464dfb 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -15,8 +15,8 @@ enum SettingsPresentation { /// Routes pushed from Settings home. Value-based navigation keeps /// destinations out of the root view tree until push — important so -/// `hidesTabBarWhenPushed()` preferences do not leak onto the home -/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles). +/// destination toolbar preferences do not leak onto the home screen +/// (and so we avoid NavigationLink + `dismiss` freeze cycles). private enum SettingsRoute: Hashable { case account case speechRecognition @@ -41,50 +41,42 @@ struct SettingsView: View { // Dynamic locale list loaded from SFSpeechRecognizer on first appear. @State private var dynamicLocales: [(id: String, onDevice: Bool)] = [] - @State private var showResetConfirmation = false @State private var path = NavigationPath() + @State private var settingsScrollTarget: String? var body: some View { NavigationStack(path: $path) { ZStack { palette.background.ignoresSafeArea() - ScrollView { - CardPageContent { - accountEntrySection - dailySection - transcriptionAndPolishSection - if presentation == .tab { - moreEntriesSection + ScrollViewReader { proxy in + ScrollView { + CardPageContent { + accountEntrySection + dailySection + transcriptionAndPolishSection + .id(SettingsDeepLink.aiService.rawValue) + if presentation == .tab { + moreEntriesSection + } + } + .modifier(SettingsScrollBottomPadding(presentation: presentation)) + } + .onChange(of: settingsScrollTarget) { _, target in + guard target == SettingsDeepLink.aiService.rawValue else { return } + Task { @MainActor in + await Task.yield() + withAnimation(Motion.soft) { + proxy.scrollTo(SettingsDeepLink.aiService.rawValue, anchor: .top) + } + settingsScrollTarget = nil } } - .modifier(SettingsScrollBottomPadding(presentation: presentation)) } } .background(palette.background) .navigationTitle("settings.title") .navigationBarTitleDisplayMode(.large) .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showResetConfirmation = true - } label: { - Image(systemName: "arrow.counterclockwise") - } - .accessibilityLabel("settings.reset.confirm") - .confirmationDialog( - "settings.reset.title", - isPresented: $showResetConfirmation, - titleVisibility: .visible - ) { - Button("common.reset", role: .destructive) { - config.reset() - SpeechHistoryStore.shared.clearAll() - } - Button("common.cancel", role: .cancel) {} - } message: { - Text("settings.reset.message") - } - } if presentation == .sheet { ToolbarItem(placement: .confirmationAction) { // Keep `dismiss` off the Settings root — pairing it @@ -98,25 +90,35 @@ struct SettingsView: View { } .task { await loadDynamicLocales() } .onAppear { - consumeClipboardDeepLinkIfNeeded() + consumeSettingsDeepLinkIfNeeded() consumeAccountDeepLinkIfNeeded() Task { await accountSession.refreshAccountData() } } .onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in - consumeClipboardDeepLinkIfNeeded() + consumeSettingsDeepLinkIfNeeded() } .onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in consumeAccountDeepLinkIfNeeded() } } + .navigationStackTabBarVisibility(isRoot: path.isEmpty) } - private func consumeClipboardDeepLinkIfNeeded() { - guard SettingsDeepLink.consumePending() == .clipboard else { return } + private func consumeSettingsDeepLinkIfNeeded() { + guard let link = SettingsDeepLink.consumePending() else { return } if !path.isEmpty { path = NavigationPath() } - path.append(SettingsRoute.clipboard) + switch link { + case .aiService: + settingsScrollTarget = SettingsDeepLink.aiService.rawValue + case .speechRecognition: + path.append(SettingsRoute.speechRecognition) + case .textPolish: + path.append(SettingsRoute.textPolish) + case .clipboard: + path.append(SettingsRoute.clipboard) + } } private func consumeAccountDeepLinkIfNeeded() { @@ -151,188 +153,76 @@ struct SettingsView: View { private var accountEntrySection: some View { CardSection("account.settings.section") { - VStack(spacing: Spacing.sm) { - Group { - switch accountSession.sessionPhase { - case .restoring: - HStack(spacing: Spacing.sm) { - ProgressView() - .tint(palette.accent) - Text("account.settings.restoring") - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - Spacer() - } - .settingsListRow() - case .signedOut: - VStack(alignment: .leading, spacing: Spacing.md) { - VStack(alignment: .leading, spacing: Spacing.xxs) { - Text("account.signedOut.title") - .font(TypeStyle.headline) - .foregroundStyle(palette.textPrimary) - Text("account.signedOut.body") - .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) - } - AccountAppleAuthorizationButton(purpose: .signIn) - .disabled(accountSession.operation != nil) - } - .padding(Spacing.md) - case let .signedIn(session): - Button { - path.append(SettingsRoute.account) - } label: { - HStack(spacing: Spacing.md) { - AccountAvatarView( - accountID: session.accountID, - displayName: session.displayName, - size: 44 - ) - - VStack(alignment: .leading, spacing: Spacing.xxs) { - Text( - session.displayName - ?? AppL10n.string( - "account.profile.fallbackName", - language: config.uiLanguage - ) - ) - .font(TypeStyle.bodyEmph) - .foregroundStyle(palette.textPrimary) - Text(accountSettingsSubtitle) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - - Spacer(minLength: Spacing.xs) - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - } - .settingsListRow() - .contentShape(Rectangle()) - .accessibilityLabel( - Text( - "\(Text("account.settings.signedIn")) \(session.accountID.uuidString)" - ) - ) - } - .buttonStyle(.plain) + Group { + switch accountSession.sessionPhase { + case .restoring: + HStack(spacing: Spacing.sm) { + ProgressView() + .tint(palette.accent) + Text("account.settings.restoring") + .font(TypeStyle.body) + .foregroundStyle(palette.textSecondary) + Spacer() } - } - .surfaceCard() + .settingsListRow() + case .signedOut: + VStack(alignment: .leading, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("account.signedOut.title") + .font(TypeStyle.headline) + .foregroundStyle(palette.textPrimary) + Text("account.signedOut.body") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + } + AccountAppleAuthorizationButton(purpose: .signIn) + .disabled(accountSession.operation != nil) + } + .padding(Spacing.md) + case let .signedIn(session): + Button { + path.append(SettingsRoute.account) + } label: { + HStack(spacing: Spacing.md) { + AccountAvatarView( + accountID: session.accountID, + displayName: session.displayName, + size: 44 + ) - if accountSession.isSignedIn { - accountRewardsEntrySection - } - } - } - } - - private var accountRewardsEntrySection: some View { - VStack(spacing: 0) { - switch accountSession.snapshotPhase { - case let .loaded(snapshot): - let totalCredits = creditTotal(snapshot) - Button { - path.append(SettingsRoute.account) - } label: { - VStack(alignment: .leading, spacing: Spacing.sm) { - HStack(alignment: .top, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.xxs) { - Text(creditRemainingText(snapshot.credits.balance)) - .font(TypeStyle.title3.monospacedDigit()) + Text( + session.displayName + ?? AppL10n.string( + "account.profile.fallbackName", + language: config.uiLanguage + ) + ) + .font(TypeStyle.bodyEmph) .foregroundStyle(palette.textPrimary) - Text("account.credits.balance") + Text(accountSettingsSubtitle) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } Spacer(minLength: Spacing.xs) - - VStack(alignment: .trailing, spacing: Spacing.xxs) { - Text(creditUsedText(snapshot.credits.usedCredits)) - Text(creditTotalText(totalCredits)) - } - .font(TypeStyle.caption) - .monospacedDigit() - .foregroundStyle(palette.textSecondary) - Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(palette.textTertiary) - .frame(minWidth: 32, minHeight: 32) - .accessibilityHidden(true) } - - AccountCreditProgress( - remaining: snapshot.credits.balance, - used: snapshot.credits.usedCredits, - showsLabels: false + .settingsListRow() + .contentShape(Rectangle()) + .accessibilityLabel( + Text( + "\(Text("account.settings.signedIn")) \(session.accountID.uuidString)" + ) ) } - .contentShape(Rectangle()) + .buttonStyle(.plain) } - .buttonStyle(.plain) - .padding(Spacing.md) - case .failed: - VStack(alignment: .leading, spacing: Spacing.sm) { - Text("account.error.load") - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - Button("account.retry") { - Task { await accountSession.refreshAccountData(force: true) } - } - .font(TypeStyle.bodyEmph) - .foregroundStyle(palette.accent) - } - .padding(Spacing.md) - case .idle, .loading: - HStack(spacing: Spacing.sm) { - ProgressView() - .tint(palette.accent) - Text("account.loading.center") - .font(TypeStyle.body) - .foregroundStyle(palette.textSecondary) - } - .settingsListRow() } - - Divider().background(palette.divider) - AccountReferralLinkView(viewModel: accountSession.referralProfile) + .surfaceCard() } - .surfaceCard() - } - - private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 { - let remaining = max(snapshot.credits.balance, 0) - let used = max(snapshot.credits.usedCredits, 0) - let (total, overflow) = remaining.addingReportingOverflow(used) - return overflow ? Int64.max : total - } - - private func creditRemainingText(_ remaining: Int64) -> String { - AppL10n.format( - "account.credits.remainingCompact", - language: config.uiLanguage, - max(remaining, 0).formatted(.number.grouping(.automatic)) - ) - } - - private func creditUsedText(_ used: Int64) -> String { - AppL10n.format( - "account.credits.usedCompact", - language: config.uiLanguage, - max(used, 0).formatted(.number.grouping(.automatic)) - ) - } - - private func creditTotalText(_ total: Int64) -> String { - AppL10n.format( - "account.credits.totalValueCompact", - language: config.uiLanguage, - total.formatted(.number.grouping(.automatic)) - ) } private var accountSettingsSubtitle: String { @@ -462,81 +352,6 @@ struct SettingsView: View { } } -private struct AccountReferralLinkView: View { - @Environment(\.themePalette) private var palette - @ObservedObject var viewModel: ReferralProfileViewModel - - var body: some View { - Group { - switch viewModel.state { - case .idle: - retryRow( - messageKey: "account.referral.loadLink", - actionKey: "account.referral.loadLink" - ) - case .loading: - HStack(spacing: Spacing.sm) { - ProgressView() - .controlSize(.small) - .tint(palette.accent) - Text("account.referral.loadingLink") - .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) - } - case .failed(let messageKey): - retryRow(messageKey: messageKey, actionKey: "account.retry") - case .loaded(let profile): - loadedContent(profile) - } - } - .padding(Spacing.md) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityIdentifier("account.referral.profile") - } - - private func loadedContent(_ profile: ReferralProfile) -> some View { - VStack(alignment: .leading, spacing: Spacing.sm) { - HStack(spacing: Spacing.md) { - Text("account.referral.equalRewardDescription") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: Spacing.xs) - AccountInvitationButton(invitationURL: profile.code.inviteURL) - } - - if viewModel.isRefreshing { - HStack(spacing: Spacing.xs) { - ProgressView() - .controlSize(.mini) - Text("account.referral.refreshingLink") - } - .font(TypeStyle.caption) - .foregroundStyle(palette.textTertiary) - } else if let refreshErrorKey = viewModel.refreshErrorKey { - retryRow(messageKey: refreshErrorKey, actionKey: "account.retry") - } - } - } - - private func retryRow( - messageKey: String, - actionKey: LocalizedStringKey - ) -> some View { - HStack(spacing: Spacing.sm) { - Text(LocalizedStringKey(messageKey)) - .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) - Spacer(minLength: Spacing.xs) - Button(actionKey) { - Task { await viewModel.refresh() } - } - .font(TypeStyle.bodyEmph) - .foregroundStyle(palette.accent) - } - } -} - // MARK: - Sheet dismiss (isolated from Settings root) private struct SettingsSheetDismissButton: View { diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index af33c6e..e064afd 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -38,17 +38,13 @@ struct TranslationPickerRow: View { .foregroundStyle(palette.textPrimary) Spacer() Menu { - ForEach(TranslationLanguageCatalog.all) { language in - Button { - apply(language) - } label: { - if currentSelectionId == language.id { - Label(displayLabel(for: language), systemImage: "checkmark") - } else { - Text(displayLabel(for: language)) - } + Picker("", selection: translationSelection) { + ForEach(TranslationLanguageCatalog.all) { language in + Text(displayLabel(for: language)) + .tag(language.id) } } + .labelsHidden() } label: { HStack(spacing: 6) { Text(currentLabel) @@ -79,6 +75,15 @@ struct TranslationPickerRow: View { TranslationLanguageCatalog.isOff(currentSelectionId) } + private var translationSelection: Binding { + Binding( + get: { currentSelectionId }, + set: { selectionID in + apply(TranslationLanguageCatalog.resolve(selectionID)) + } + ) + } + private func displayLabel(for language: TranslationLanguage) -> String { if language.id == TranslationLanguageCatalog.offLocaleId { return AppL10n.string("settings.translation.off") diff --git a/OSGKeyboard/Views/TypingInputSettingsView.swift b/OSGKeyboard/Views/TypingInputSettingsView.swift index 564634a..74f2865 100644 --- a/OSGKeyboard/Views/TypingInputSettingsView.swift +++ b/OSGKeyboard/Views/TypingInputSettingsView.swift @@ -18,23 +18,28 @@ struct TypingInputSettingsView: View { var body: some View { List { - Section(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) { + Section { Picker( - AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage), selection: $configuration.schema ) { ForEach(TypingInputSchema.allCases) { schema in Text(AppL10n.string(schema.labelKey, language: config.uiLanguage)) + .font(TypeStyle.body) .tag(schema) } + } label: { + Text(AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage)) + .font(TypeStyle.body) } .pickerStyle(.inline) + } header: { + Text(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) + .font(TypeStyle.caption2) } Section { ForEach(PinyinFuzzyPair.allCases) { pair in Toggle( - pair.displayName, isOn: Binding( get: { configuration.fuzzyPairs.contains(pair) }, set: { enabled in @@ -42,23 +47,30 @@ struct TypingInputSettingsView: View { deployUpdatedSchemas() } ) - ) + ) { + Text(pair.displayName) + .font(TypeStyle.body) + } } } header: { Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage)) + .font(TypeStyle.caption2) } footer: { Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage)) + .font(TypeStyle.caption2) } - Section(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage)) { + Section { HStack { Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage)) + .font(TypeStyle.body) Spacer() if isDeploying { ProgressView() .controlSize(.small) } else { Text(statusText) + .font(TypeStyle.body) .foregroundStyle( hasDeploymentError ? palette.danger : palette.textSecondary ) @@ -68,19 +80,26 @@ struct TypingInputSettingsView: View { Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) { deployUpdatedSchemas() } + .font(TypeStyle.body) .disabled(isDeploying) + } header: { + Text(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage)) + .font(TypeStyle.caption2) } Section { Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) { showClearHabitsConfirmation = true } + .font(TypeStyle.body) .disabled(isDeploying) .foregroundStyle(palette.danger) } footer: { Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage)) + .font(TypeStyle.caption2) } } + .listSectionSpacing(CardLayoutMetrics.sectionSpacing) .scrollContentBackground(.hidden) .background(palette.background) .navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage)) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index cf2da6d..5356aa9 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -350,7 +350,6 @@ "settings.keyboardHaptic.off" = "Off"; "settings.keyboardHaptic.light" = "Light"; "settings.keyboardHaptic.strong" = "Strong"; -"settings.cursorDragNavigation.title" = "Drag empty area to move cursor"; "settings.systemPrompt.reset" = "Reset"; "settings.asrLocale" = "ASR locale"; "settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; @@ -495,8 +494,11 @@ "home.setup.permission.speech" = "Speech recognition is off — voice input won't work."; "home.setup.permission.both" = "Microphone and speech recognition are off — voice input won't work."; "home.setup.permission.request" = "Next"; -"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab."; -"home.setup.polishKeyMissing" = "Add an API key in Settings to enable AI polish. Without it, raw ASR text is inserted."; +"home.setup.cloudIncomplete" = "Cloud recognition and AI polish need API keys."; +"home.setup.polishKeyMissing" = "Add an API key to enable AI polish. Without it, local dictation still inserts raw recognized text."; +"home.setup.byok.configure" = "Configure API Key"; +"home.setup.credits.alternative" = "Or use OSG credits for transcription and AI without managing API keys."; +"home.setup.credits.open" = "Use Credits"; "home.setup.keyboardHint" = "Don't see OSGKeyboard? Add it in iOS Settings."; "home.setup.keyboardHint.dismiss" = "Got it"; "home.flow.starting" = "Starting voice session…"; @@ -514,11 +516,12 @@ "home.preview.placeholder" = "Tap to type and test…"; "home.card.history.empty" = "No voice transcripts yet"; "home.card.dictionary.empty" = "No words yet"; -"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your Rime typing"; -"home.card.dictionary.smart.empty" = "No suggestions yet"; +"home.card.dictionary.smart.subtitle" = "Frequently used new terms discovered from your typing"; +"home.card.dictionary.smart.empty" = "No recommended terms yet"; +"home.card.dictionary.smart.addHint" = "Tap to add frequently used terms to your personal dictionary"; "home.card.dictionary.smart.accessibilityHint" = "Used %lld times. Double-tap to add it to your personal dictionary."; "home.card.dictionary.confirm.title" = "Add “%@” to your personal dictionary?"; -"home.card.dictionary.confirm.message" = "Rime has recorded this term %lld times. Once added, it will also improve speech recognition and AI polishing."; +"home.card.dictionary.confirm.message" = "You’ve used this term %lld times. Once added, it will also improve speech recognition and AI polishing."; "home.card.dictionary.confirm.add" = "Add to dictionary"; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; "home.wide.mode.cloud" = "Cloud"; @@ -562,11 +565,11 @@ "skills.action.install" = "Install"; "skills.action.uninstall" = "Uninstall"; "skills.action.reinstallShortcut" = "Reinstall Shortcut"; -"skills.reply.name" = "Reply in source language"; +"skills.reply.name" = "Reply"; "skills.reply.description" = "Understand the copied text and reply naturally in its primary language."; "skills.playfulReply.name" = "Playful reply"; "skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate."; -"skills.replyInSourceLanguage.name" = "Reply in source language"; +"skills.replyInSourceLanguage.name" = "Reply"; "skills.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language."; "skills.summarize.name" = "Summarize key points"; "skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present."; @@ -660,7 +663,7 @@ "polishStyles.learn.generating" = "Generating…"; "polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving."; "polishStyles.learn.limit" = "Delete a custom style before generating another one."; -"polishStyles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available."; +"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available."; "polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again."; "polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again."; "polishStyles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again."; @@ -724,7 +727,6 @@ "settings.personalDictionary.empty.title" = "No words yet"; "settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud"; "settings.personalDictionary.search.prompt" = "Search words"; -"settings.personalDictionary.usageCount" = "%lld uses"; "settings.personalDictionary.add.title" = "Add word"; "settings.personalDictionary.edit.title" = "Edit word"; "settings.personalDictionary.add.field" = "Word"; @@ -830,6 +832,8 @@ "account.signedOut.title" = "Your OSG account"; "account.signedOut.body" = "Sign in to use managed credits and referrals."; "account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account."; +"home.accountRewards.signedOut.title" = "Sign in for free credits"; +"home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits."; "account.signIn.apple" = "Sign in with Apple"; "account.signIn.hint" = "Creates or opens your optional OSG account."; "account.signIn.loading" = "Signing in…"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 9c64638..c6dc9d0 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -350,7 +350,6 @@ "settings.keyboardHaptic.off" = "关"; "settings.keyboardHaptic.light" = "轻"; "settings.keyboardHaptic.strong" = "强"; -"settings.cursorDragNavigation.title" = "触摸空白区域移动光标"; "settings.systemPrompt.reset" = "重置"; "settings.asrLocale" = "识别语言"; "settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; @@ -494,8 +493,11 @@ "home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; "home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。"; "home.setup.permission.request" = "下一步"; -"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。"; -"home.setup.polishKeyMissing" = "请先在「设置」填写 API Key 才能润色;未填写时会直接插入原始识别结果。"; +"home.setup.cloudIncomplete" = "云端识别和 AI 润色需要配置 API Key。"; +"home.setup.polishKeyMissing" = "配置 API Key 后可启用 AI 润色;未配置时,本地听写仍会插入原始识别文本。"; +"home.setup.byok.configure" = "配置 API Key"; +"home.setup.credits.alternative" = "也可以使用 OSG 积分完成语音转写和 AI,无需管理 API Key。"; +"home.setup.credits.open" = "使用积分"; "home.setup.keyboardHint" = "列表里没有?去系统设置里添加键盘。"; "home.setup.keyboardHint.dismiss" = "知道了"; "home.flow.starting" = "正在启动语音会话…"; @@ -513,11 +515,12 @@ "home.preview.placeholder" = "点这里试试键盘"; "home.card.history.empty" = "还没有语音识别记录"; "home.card.dictionary.empty" = "还没有词条"; -"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词"; -"home.card.dictionary.smart.empty" = "暂无推荐词"; +"home.card.dictionary.smart.subtitle" = "从中英文输入习惯中发现的高频新词"; +"home.card.dictionary.smart.empty" = "暂无推荐热词"; +"home.card.dictionary.smart.addHint" = "点击添加常用词到个性词库"; "home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。"; "home.card.dictionary.confirm.title" = "将“%@”加入个性词库?"; -"home.card.dictionary.confirm.message" = "Rime 已记录这个词 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。"; +"home.card.dictionary.confirm.message" = "这个词已使用 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。"; "home.card.dictionary.confirm.add" = "加入词库"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; "home.wide.mode.cloud" = "云端"; @@ -561,11 +564,11 @@ "skills.action.install" = "安装"; "skills.action.uninstall" = "卸载"; "skills.action.reinstallShortcut" = "重新安装捷径"; -"skills.reply.name" = "原语言回复"; +"skills.reply.name" = "回复"; "skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。"; "skills.playfulReply.name" = "俏皮回复"; "skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。"; -"skills.replyInSourceLanguage.name" = "用原语言回复"; +"skills.replyInSourceLanguage.name" = "回复"; "skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。"; "skills.summarize.name" = "总结要点"; "skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。"; @@ -659,7 +662,7 @@ "polishStyles.learn.generating" = "生成中…"; "polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; -"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。"; +"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。"; "polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。"; "polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。"; @@ -723,7 +726,6 @@ "settings.personalDictionary.empty.title" = "还没有词条"; "settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步"; "settings.personalDictionary.search.prompt" = "搜索词条"; -"settings.personalDictionary.usageCount" = "使用 %lld 次"; "settings.personalDictionary.add.title" = "添加词条"; "settings.personalDictionary.edit.title" = "编辑词条"; "settings.personalDictionary.add.field" = "词条"; @@ -829,6 +831,8 @@ "account.signedOut.title" = "OSG 账号"; "account.signedOut.body" = "登录后可使用托管积分与邀请功能。"; "account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。"; +"home.accountRewards.signedOut.title" = "登录领免费积分"; +"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。"; "account.signIn.apple" = "通过 Apple 登录"; "account.signIn.hint" = "创建或打开可选的 OSG 账号。"; "account.signIn.loading" = "正在登录…"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 74696bf..403c15d 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -68,6 +68,11 @@ public final class KeyboardViewController: UIInputViewController { /// One random ID per keyboard presentation. The repository splits this ID /// into independent UTC-day fragments when a presentation crosses midnight. private var keyboardUsageSessionID = UUID() + /// UIKit can reuse this controller across host apps. Keep document-scoped + /// candidate state separate from the heavy typing-engine lifetime. + private var isKeyboardPresentationActive = false + private var isTypingSurfaceActive = false + private var typingDocumentPresentationID: UInt64? private var memoryTelemetryContext: String { let language = typingSessionStorage?.language.rawValue ?? "-" @@ -208,6 +213,9 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + isKeyboardPresentationActive = false + typingDocumentPresentationID = nil + typingSessionStorage?.endDocumentPresentation() AnalyticsExtensionService.shared.keyboardWillDisappear() assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask = nil @@ -243,8 +251,8 @@ public final class KeyboardViewController: UIInputViewController { if !preserve { prepareSurfaceForNextPresentation() } - if state.surface == .typing { - typingSession.leaveTypingMode() + if state.surface != .typing { + deactivateTypingSurface() } if preserve { return @@ -255,6 +263,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + isKeyboardPresentationActive = true OSGDiag.log( "KVC.viewWillAppear begin surface=\(state.surface.rawValue) " + "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())", @@ -263,6 +272,7 @@ public final class KeyboardViewController: UIInputViewController { recordMemory("KVC.viewWillAppear.begin") setNeedsUpdateOfScreenEdgesDeferringSystemGestures() configureDictationBehavior() + state.hasFullAccess = hasFullAccess KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) // Refresh only Flow/config state; edit targets come from verified OSG insertions. flowCoordinator.refreshSessionState() @@ -278,8 +288,7 @@ public final class KeyboardViewController: UIInputViewController { recordMemory("KVC.viewWillAppear.afterHaptics") if state.surface == .typing { OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot") - typingSession.enterTypingMode() - refreshEnglishSupplementaryLexicon() + activateTypingPresentationIfNeeded() recordMemory("KVC.viewWillAppear.afterTypingEnter") } clipboardCapture.keyboardDidAppear() @@ -322,6 +331,7 @@ public final class KeyboardViewController: UIInputViewController { heightPhase = .presented lockPresentedKeyboardHeight() refreshReturnKeyRole() + scheduleTypingDocumentSynchronization() // Presentation math is locked before arming the PiP handoff. DispatchQueue.main.async { [weak self] in guard let self, self.heightPhase == .presented else { return } @@ -357,7 +367,7 @@ public final class KeyboardViewController: UIInputViewController { public override func textDidChange(_ textInput: UITextInput?) { super.textDidChange(textInput) refreshReturnKeyRole() - typingSession.synchronizeEnglishDocumentContext() + synchronizeTypingDocumentContext() textInserter?.refreshEditingAvailability() lastInputEditCoordinator?.refreshContext() } @@ -365,7 +375,7 @@ public final class KeyboardViewController: UIInputViewController { public override func selectionDidChange(_ textInput: UITextInput?) { super.selectionDidChange(textInput) refreshReturnKeyRole() - typingSession.synchronizeEnglishDocumentContext(caretMoved: true) + synchronizeTypingDocumentContext(caretMoved: true) textInserter?.refreshEditingAvailability() lastInputEditCoordinator?.refreshContext() } @@ -395,10 +405,9 @@ public final class KeyboardViewController: UIInputViewController { ) flowCoordinator.cancelPipelineUnlessAwaitingResult() if state.surface == .typing { - typingSession.leaveTypingMode() applySurface(.voice) } else { - typingSession.leaveTypingMode() + deactivateTypingSurface() } } @@ -423,9 +432,15 @@ public final class KeyboardViewController: UIInputViewController { self?.insertTextIntoDocument(text, source: source) }, deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() }, - contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput }, + contextBeforeInput: { [weak self] in + guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil } + return self.textDocumentProxy.documentContextBeforeInput + }, fieldContextProvider: { [weak self] in self?.captureFieldContext() }, - selectedText: { [weak self] in self?.textDocumentProxy.selectedText }, + selectedText: { [weak self] in + guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil } + return self.textDocumentProxy.selectedText + }, scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() }, editHintScheduler: editHintScheduler ) @@ -439,6 +454,7 @@ public final class KeyboardViewController: UIInputViewController { onConfigChanged: { [weak self] in // Only an already-live typing session can be showing the setup // error; never force-create one just to retry. + self?.typingSessionStorage?.reloadPersonalDictionaryTerms() self?.typingSessionStorage?.retryPrepareAfterResourceDeployment() } ) @@ -502,7 +518,7 @@ public final class KeyboardViewController: UIInputViewController { clipboardCapture = ClipboardCaptureCoordinator(state: state) clipboardCapture.configure( isSecure: { [weak self] in - self?.textDocumentProxy.isSecureTextEntry ?? false + self?.textDocumentProxy.isSecureTextEntry ?? true }, hasFullAccess: { [weak self] in self?.hasFullAccess ?? false @@ -666,8 +682,14 @@ public final class KeyboardViewController: UIInputViewController { state.$surface .receive(on: RunLoop.main) - .sink { [weak self] _ in - self?.refreshKeyboardHeight() + .sink { [weak self] surface in + guard let self else { return } + if surface == .typing { + self.activateTypingPresentationIfNeeded() + } else { + self.deactivateTypingSurface() + } + self.refreshKeyboardHeight() } .store(in: &cancellables) } @@ -685,6 +707,9 @@ public final class KeyboardViewController: UIInputViewController { return } guard state.surface != surface else { + if surface == .typing { + activateTypingPresentationIfNeeded() + } refreshKeyboardHeight() return } @@ -695,14 +720,68 @@ public final class KeyboardViewController: UIInputViewController { state.surface = surface recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)") if surface == .typing { - typingSession.enterTypingMode() - refreshEnglishSupplementaryLexicon() + activateTypingPresentationIfNeeded() } else { - typingSession.leaveTypingMode() + deactivateTypingSurface() } refreshKeyboardHeight() } + private func activateTypingPresentationIfNeeded() { + guard isKeyboardPresentationActive, + state.surface == .typing, + typingDocumentPresentationID == nil else { + return + } + if !isTypingSurfaceActive { + typingSession.enterTypingMode() + isTypingSurfaceActive = true + } + typingDocumentPresentationID = typingSession.beginDocumentPresentation() + refreshEnglishSupplementaryLexicon() + if heightPhase == .presented { + refreshReturnKeyRole() + scheduleTypingDocumentSynchronization() + } + } + + private func deactivateTypingSurface() { + guard isTypingSurfaceActive || typingDocumentPresentationID != nil else { return } + typingDocumentPresentationID = nil + typingSessionStorage?.leaveTypingMode() + isTypingSurfaceActive = false + } + + private func scheduleTypingDocumentSynchronization() { + guard let presentationID = typingDocumentPresentationID else { return } + DispatchQueue.main.async { [weak self] in + guard let self, + self.isKeyboardPresentationActive, + self.state.surface == .typing, + self.typingDocumentPresentationID == presentationID else { + return + } + self.synchronizeTypingDocumentContext( + caretMoved: true, + presentationID: presentationID + ) + } + } + + private func synchronizeTypingDocumentContext( + caretMoved: Bool = false, + presentationID: UInt64? = nil + ) { + guard let activeID = presentationID ?? typingDocumentPresentationID, + typingDocumentPresentationID == activeID else { + return + } + typingSessionStorage?.synchronizeEnglishDocumentContext( + caretMoved: caretMoved, + presentationID: activeID + ) + } + private func applyPreferredSurfaceOnOpen() { let preference = TypingInputConfiguration.preferredOpenPreference() let resolved = KeyboardOpenSurfacePolicy.resolve( @@ -716,10 +795,10 @@ public final class KeyboardViewController: UIInputViewController { + "locksTyping=\(state.locksTypingSurface ? 1 : 0)", category: "boot" ) - applySurface(resolved) if resolved == .typing, let language = preference.typingLanguage { _ = typingSession.setLanguage(language) } + applySurface(resolved) } /// When not remembering, snap to the static open preference while hidden @@ -842,24 +921,31 @@ public final class KeyboardViewController: UIInputViewController { assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask = nil refreshAssistantFieldAction() - let isSecure = textDocumentProxy.isSecureTextEntry ?? false + // An unavailable trait is not proof that the new host field is safe. + // Fail closed until UIKit supplies an explicit non-secure value. + let isSecure = textDocumentProxy.isSecureTextEntry ?? true state.setSecureTextEntry(isSecure) clipboardCapture?.secureEntryDidChange(isSecure: isSecure) // Secure fields must not run English autocomplete / autocorrect / learning. - typingSession.suggestionsEnabled = !isSecure - typingSession.syncAutocapitalization() + typingSessionStorage?.suggestionsEnabled = !isSecure + if !isSecure { + typingSessionStorage?.syncAutocapitalization() + } } private func installTypingContextProviders() { typingSession.precedingTextProvider = { [weak self] in - self?.textDocumentProxy.documentContextBeforeInput + guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil } + return self.textDocumentProxy.documentContextBeforeInput } typingSession.followingTextProvider = { [weak self] in - self?.textDocumentProxy.documentContextAfterInput + guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return nil } + return self.textDocumentProxy.documentContextAfterInput } typingSession.autocapitalizationModeProvider = { [weak self] in - Self.typingAutocapitalizationMode( - for: self?.textDocumentProxy.autocapitalizationType ?? .sentences + guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return .none } + return Self.typingAutocapitalizationMode( + for: self.textDocumentProxy.autocapitalizationType ?? .sentences ) } } @@ -1037,6 +1123,7 @@ public final class KeyboardViewController: UIInputViewController { // MARK: - App context private func detectAndStoreAppContext() { + guard textDocumentProxy.isSecureTextEntry == false else { return } let preceding = textDocumentProxy.documentContextBeforeInput let store = AppGroupStore() let detector = AppContextDetector() @@ -1048,9 +1135,9 @@ public final class KeyboardViewController: UIInputViewController { } private func captureFieldContext() -> FlowFieldContext { - let isSecure = textDocumentProxy.isSecureTextEntry ?? false - let preceding = textDocumentProxy.documentContextBeforeInput - let following = textDocumentProxy.documentContextAfterInput + let isSecure = textDocumentProxy.isSecureTextEntry ?? true + let preceding = isSecure ? nil : textDocumentProxy.documentContextBeforeInput + let following = isSecure ? nil : textDocumentProxy.documentContextAfterInput let isAvailable = preceding != nil || following != nil let isEmpty = isAvailable && (preceding ?? "").isEmpty && (following ?? "").isEmpty diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 60d7463..60f24d8 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -88,6 +88,7 @@ public struct AppGroupPersistor { guard AppGroup.isAvailable else { return } let store = AppGroupStore() state.engineMode = store.engineMode + state.localeId = store.localeId let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect( until: protectTranslationUntil ) diff --git a/OSGKeyboardExt/Typing/CandidateExpandGridView.swift b/OSGKeyboardExt/Typing/CandidateExpandGridView.swift index c65a953..9b9bb73 100644 --- a/OSGKeyboardExt/Typing/CandidateExpandGridView.swift +++ b/OSGKeyboardExt/Typing/CandidateExpandGridView.swift @@ -12,7 +12,7 @@ struct CandidateExpandGridView: UIViewRepresentable { var candidates: [TypingCandidate] var textColor: UIColor var dividerColor: UIColor - var onSelect: (Int) -> Void + var onSelect: (TypingCandidate) -> Void func makeCoordinator() -> Coordinator { Coordinator(onSelect: onSelect) @@ -73,10 +73,10 @@ struct CandidateExpandGridView: UIViewRepresentable { var candidates: [TypingCandidate] = [] var textColor: UIColor = .label var dividerColor: UIColor = .separator - var onSelect: (Int) -> Void + var onSelect: (TypingCandidate) -> Void weak var collectionView: UICollectionView? - init(onSelect: @escaping (Int) -> Void) { + init(onSelect: @escaping (TypingCandidate) -> Void) { self.onSelect = onSelect } @@ -105,7 +105,8 @@ struct CandidateExpandGridView: UIViewRepresentable { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { collectionView.deselectItem(at: indexPath, animated: false) - onSelect(indexPath.item) + guard candidates.indices.contains(indexPath.item) else { return } + onSelect(candidates[indexPath.item]) } private func isLastRow(index: Int) -> Bool { diff --git a/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift b/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift index 6aba986..9eae3c0 100644 --- a/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift +++ b/OSGKeyboardExt/Typing/KeyboardSurfaceRoot.swift @@ -82,11 +82,6 @@ struct KeyboardSurfaceRoot: View { // lingers (a slow Universal Clipboard read, a system alert). .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .animation(.easeInOut(duration: 0.15), value: state.surface) - .onChange(of: state.surface) { _, newSurface in - if newSurface != .typing { - typing.leaveTypingMode() - } - } } private func wrappedInsert(_ text: String) { @@ -124,7 +119,9 @@ struct KeyboardSurfaceRoot: View { onClear: state.clearClipboardHistory, onInsert: { state.insertClipboardText($0) }, onDelete: { state.deleteClipboardHistoryEntry($0) }, - pastePermissionHint: nil + pastePermissionHint: state.needsClipboardFullAccessHint + ? ExtL10n.string("keyboard.clipboard.panel.fullAccessHint") + : nil ) } } diff --git a/OSGKeyboardExt/Typing/TypingRootView.swift b/OSGKeyboardExt/Typing/TypingRootView.swift index 38f9ce4..29627b6 100644 --- a/OSGKeyboardExt/Typing/TypingRootView.swift +++ b/OSGKeyboardExt/Typing/TypingRootView.swift @@ -70,8 +70,8 @@ struct TypingRootView: View { typingKeySurface .padding(.top, TypingLayoutMetrics.verticalKeySpacing) .opacity(expanded ? 0 : 1) - .allowsHitTesting(!expanded) - .accessibilityHidden(expanded) + .allowsHitTesting(!expanded && canAcceptTypingKeys) + .accessibilityHidden(expanded || !canAcceptTypingKeys) if expanded || candidatePanelMounted { expandedCandidatePanel @@ -141,6 +141,14 @@ struct TypingRootView: View { !typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty } + private var isChineseEnginePreparing: Bool { + typing.language == .chinese && typing.isPreparingEngine + } + + private var canAcceptTypingKeys: Bool { + typing.language == .english || typing.engineReady + } + private var idleTopBar: some View { ZStack { KeyboardTopControls( @@ -157,6 +165,10 @@ struct TypingRootView: View { if let err = typing.lastError { typingErrorLabel(err) + } else if isChineseEnginePreparing { + ProgressView() + .controlSize(.small) + .accessibilityLabel(ExtL10n.text("keyboard.typing.preparingA11y")) } // iOS-style editing cluster (undo / redo / copy / cut) — iPad only, @@ -238,16 +250,17 @@ struct TypingRootView: View { .fill(palette.dividerStrong) .frame(width: 1, height: 18) } - englishQuickTypeSlot(candidate, index: index) + englishQuickTypeSlot(candidate) } } .frame(maxWidth: .infinity) .padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset) } - private func englishQuickTypeSlot(_ candidate: TypingCandidate, index: Int) -> some View { + private func englishQuickTypeSlot(_ candidate: TypingCandidate) -> some View { let label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular + let candidateRevision = typing.candidateRevision return Text(label) .font(.system(size: 17, weight: weight)) .foregroundStyle(palette.textPrimary) @@ -256,20 +269,32 @@ struct TypingRootView: View { .frame(maxWidth: .infinity, minHeight: 40) .contentShape(Rectangle()) .onTapGesture { - apply(typing.selectCandidate(at: index)) + apply( + typing.selectCandidate( + id: candidate.id, + candidateRevision: candidateRevision + ) + ) } .accessibilityAddTraits(.isButton) .accessibilityLabel(candidate.text) } private var chineseCandidateStrip: some View { - ScrollView(.horizontal, showsIndicators: false) { + let candidateRevision = typing.candidateRevision + return ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: Spacing.xs) { if typing.composition.candidates.isEmpty { selectedCandidateLabel(text: typing.composition.preedit) - } else if typing.isCandidatePanelExpanded { - candidateChip(text: typing.composition.candidates[0].text) { - apply(typing.selectCandidate(at: 0)) + } else if typing.isCandidatePanelExpanded, + let candidate = typing.composition.candidates.first { + candidateChip(text: candidate.text) { + apply( + typing.selectCandidate( + id: candidate.id, + candidateRevision: candidateRevision + ) + ) } } else { ForEach( @@ -282,7 +307,12 @@ struct TypingRootView: View { ) { index, candidate in if index == 0 { candidateChip(text: candidate.text) { - apply(typing.selectCandidate(at: index)) + apply( + typing.selectCandidate( + id: candidate.id, + candidateRevision: candidateRevision + ) + ) } } else { Text(candidate.text) @@ -292,7 +322,12 @@ struct TypingRootView: View { .frame(height: 40) .contentShape(Rectangle()) .onTapGesture { - apply(typing.selectCandidate(at: index)) + apply( + typing.selectCandidate( + id: candidate.id, + candidateRevision: candidateRevision + ) + ) } .accessibilityAddTraits(.isButton) .accessibilityLabel(candidate.text) @@ -393,15 +428,21 @@ struct TypingRootView: View { /// UIKit-recycled labels — no SwiftUI Button per candidate. private var expandedCandidatePanel: some View { - CandidateExpandGridView( + let candidateRevision = typing.candidateRevision + return CandidateExpandGridView( candidates: typing.composition.candidates, textColor: UIColor(keyTextColor), dividerColor: UIColor(palette.dividerStrong), - onSelect: { index in + onSelect: { candidate in var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { - apply(typing.selectCandidate(at: index)) + apply( + typing.selectCandidate( + id: candidate.id, + candidateRevision: candidateRevision + ) + ) } } ) @@ -646,6 +687,7 @@ struct TypingRootView: View { } private func commitTypingKey(_ key: TypingKeyHitTarget) { + guard canAcceptTypingKeys else { return } switch key.id { case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue: typing.setPage(typing.page == .letters ? .numbers : .letters) diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift index a4f14c4..8686196 100644 --- a/OSGKeyboardExt/Views/AIKeyboardView.swift +++ b/OSGKeyboardExt/Views/AIKeyboardView.swift @@ -581,10 +581,12 @@ struct AIKeyboardView: View { if let expectedSkillID = oobeExpectedSkillID { return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID } } - guard let newest = clipboardHistory.newestEntry, - let snapshot = semanticRanking.snapshot, + guard let newest = clipboardHistory.newestEntry else { return [] } + guard let snapshot = semanticRanking.snapshot, snapshot.entryID == newest.id else { - return [] + return state.clipboardSkillCatalog.filter { + $0.id == AIClipboardSkillCatalog.replyID + } } return ClipboardSkillSemanticRanker.recommended( skills: state.clipboardSkillCatalog, diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index fbf40bf..94976af 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -152,11 +152,10 @@ "keyboard.redoA11y" = "Redo"; "keyboard.copyA11y" = "Copy"; "keyboard.cutA11y" = "Cut"; -"keyboard.cursorDrag.hint" = "Hold and drag to move the cursor"; -"keyboard.cursorDrag.centerHint" = "Drag to move the cursor"; "keyboard.nextKeyboardA11y" = "Next keyboard"; "keyboard.nextKeyboardA11yHint" = "Tap to switch to the next keyboard. Touch and hold to see all keyboards."; "keyboard.typing.setupA11yHint" = "Open OSGKeyboard to finish input method setup."; +"keyboard.typing.preparingA11y" = "Preparing Chinese input"; /* Flow session (keyboard) */ "keyboard.flow.sessionInactive" = "Voice session off"; @@ -211,6 +210,7 @@ "keyboard.clipboard.guide.cta" = "Open Settings"; "keyboard.clipboard.panel.title" = "Clipboard"; "keyboard.clipboard.panel.empty" = "No history yet"; +"keyboard.clipboard.panel.fullAccessHint" = "New copies are paused because Allow Full Access is off. Existing history remains available."; "keyboard.clipboard.panel.delete" = "Delete"; "keyboard.clipboard.panel.close" = "Close clipboard"; "keyboard.clipboard.panel.closeHint" = "Return to the keyboard."; @@ -319,9 +319,9 @@ "keyboard.ai.error.requestTimeout" = "AI response timed out. Try again"; "keyboard.ai.error.requestFailed" = "AI response failed. Try again"; "keyboard.ai.error.clipboardUnavailable" = "This clipboard suggestion expired. Copy the text again"; -"keyboard.ai.skill.reply" = "Same-language reply"; +"keyboard.ai.skill.reply" = "Reply"; "keyboard.ai.skill.playfulReply" = "Playful"; -"keyboard.ai.skill.replyInSourceLanguage" = "Same language"; +"keyboard.ai.skill.replyInSourceLanguage" = "Reply"; "keyboard.ai.skill.summarize" = "Key points"; "keyboard.ai.skill.openLink" = "Open"; "keyboard.ai.skill.summarizeWebPage" = "Web summary"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index d9c1122..c9f8c89 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -152,11 +152,10 @@ "keyboard.redoA11y" = "重做"; "keyboard.copyA11y" = "拷贝"; "keyboard.cutA11y" = "剪切"; -"keyboard.cursorDrag.hint" = "按住并拖动以移动光标"; -"keyboard.cursorDrag.centerHint" = "拖动移动光标"; "keyboard.nextKeyboardA11y" = "切换键盘"; "keyboard.nextKeyboardA11yHint" = "轻点切换到下一个键盘;长按查看全部键盘。"; "keyboard.typing.setupA11yHint" = "打开 OSGKeyboard 完成输入法初始化。"; +"keyboard.typing.preparingA11y" = "正在准备中文输入"; /* Flow session (keyboard) */ "keyboard.flow.sessionInactive" = "语音会话未启动"; @@ -211,6 +210,7 @@ "keyboard.clipboard.guide.cta" = "去设置开启"; "keyboard.clipboard.panel.title" = "剪贴板"; "keyboard.clipboard.panel.empty" = "暂无历史记录"; +"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。"; "keyboard.clipboard.panel.delete" = "删除"; "keyboard.clipboard.panel.close" = "关闭剪贴板"; "keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。"; @@ -319,9 +319,9 @@ "keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试"; "keyboard.ai.error.requestFailed" = "AI 回答失败,请重试"; "keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本"; -"keyboard.ai.skill.reply" = "原语言回复"; +"keyboard.ai.skill.reply" = "回复"; "keyboard.ai.skill.playfulReply" = "俏皮回复"; -"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复"; +"keyboard.ai.skill.replyInSourceLanguage" = "回复"; "keyboard.ai.skill.summarize" = "总结要点"; "keyboard.ai.skill.openLink" = "打开"; "keyboard.ai.skill.summarizeWebPage" = "总结网页"; diff --git a/OSGKeyboardExtTests/CandidatePanelExpandTests.swift b/OSGKeyboardExtTests/CandidatePanelExpandTests.swift index 44bc90d..4ffc9bd 100644 --- a/OSGKeyboardExtTests/CandidatePanelExpandTests.swift +++ b/OSGKeyboardExtTests/CandidatePanelExpandTests.swift @@ -84,6 +84,69 @@ final class CandidatePanelExpandTests: XCTestCase { XCTAssertFalse(typing.isCandidatePanelExpanded) XCTAssertTrue(typing.composition.candidates.isEmpty) } + + func testEndingDocumentPresentationClearsChineseStateWithoutTeardown() { + let engine = StubRimeEngine() + let typing = TypingSessionController(engine: { engine }) + _ = typing.beginDocumentPresentation() + _ = typing.handleKey("n") + XCTAssertFalse(typing.composition.candidates.isEmpty) + + typing.endDocumentPresentation() + + XCTAssertTrue(typing.composition.candidates.isEmpty) + XCTAssertEqual(engine.teardownCallCount, 0) + XCTAssertEqual(engine.clearCompositionCallCount, 1) + } + + func testCandidateSelectionRejectsReplacedSnapshot() { + let engine = StubRimeEngine() + let typing = TypingSessionController(engine: { engine }) + _ = typing.handleKey("n") + guard let staleCandidateID = typing.composition.candidates.first?.id else { + return XCTFail("expected candidate") + } + let staleRevision = typing.candidateRevision + + _ = typing.handleKey("i") + let output = typing.selectCandidate( + id: staleCandidateID, + candidateRevision: staleRevision + ) + + XCTAssertEqual(output, .none) + XCTAssertFalse(typing.composition.candidates.isEmpty) + } + + func testPrepareContinuesWhenHostBecomesHeavyBeforeTaskStarts() async { + let hostState = HostHeavyState() + let engine = StubRimeEngine() + let prepared = expectation(description: "engine prepared") + engine.onPrepare = { prepared.fulfill() } + let typing = TypingSessionController( + engine: { engine }, + hostHeavyProvider: { hostState.isHeavy } + ) + + typing.enterTypingMode() + hostState.isHeavy = true + await Task.yield() + XCTAssertTrue(typing.isPreparingEngine) + XCTAssertEqual(engine.prepareCallCount, 0) + + hostState.isHeavy = false + await fulfillment(of: [prepared], timeout: 1) + await Task.yield() + + XCTAssertEqual(engine.prepareCallCount, 1) + XCTAssertTrue(typing.engineReady) + XCTAssertFalse(typing.isPreparingEngine) + } +} + +@MainActor +private final class HostHeavyState { + var isHeavy = false } // Minimal engine that returns a two-candidate snapshot after any letter. @@ -94,9 +157,17 @@ private final class StubRimeEngine: RimeEngineBridging { var schema: TypingInputSchema = .fullPinyin /// Optional override applied on the next processBackspace. var nextComposition: TypingComposition? + var teardownCallCount = 0 + var clearCompositionCallCount = 0 + var prepareCallCount = 0 + var onPrepare: (() -> Void)? - func prepare() async throws {} + func prepare() async throws { + prepareCallCount += 1 + onPrepare?() + } func teardown() { + teardownCallCount += 1 composition = .empty } @@ -116,8 +187,8 @@ private final class StubRimeEngine: RimeEngineBridging { composition = TypingComposition( preedit: String(character), candidates: [ - TypingCandidate(text: "你"), - TypingCandidate(text: "泥") + TypingCandidate(id: "first", text: "你"), + TypingCandidate(id: "second", text: "泥") ] ) return nil @@ -158,6 +229,7 @@ private final class StubRimeEngine: RimeEngineBridging { } func clearComposition() { + clearCompositionCallCount += 1 composition = .empty } } diff --git a/OSGKeyboardExtTests/EnglishTypingTests.swift b/OSGKeyboardExtTests/EnglishTypingTests.swift index 4324b5f..5b83148 100644 --- a/OSGKeyboardExtTests/EnglishTypingTests.swift +++ b/OSGKeyboardExtTests/EnglishTypingTests.swift @@ -179,6 +179,79 @@ final class EnglishTypingTests: XCTestCase { XCTAssertEqual(typing.composition.preedit, "h") } + @MainActor + func testNewDocumentPresentationRehydratesEnglishContext() { + var preceding = "hel" + let typing = makeTypingSession() + typing.precedingTextProvider = { preceding } + typing.followingTextProvider = { "" } + typing.enterTypingMode() + + let firstPresentation = typing.beginDocumentPresentation() + typing.synchronizeEnglishDocumentContext( + caretMoved: true, + presentationID: firstPresentation + ) + XCTAssertEqual(typing.composition.preedit.lowercased(), "hel") + XCTAssertFalse(typing.composition.candidates.isEmpty) + + typing.endDocumentPresentation() + XCTAssertTrue(typing.composition.preedit.isEmpty) + XCTAssertTrue(typing.composition.candidates.isEmpty) + + preceding = "wor" + let secondPresentation = typing.beginDocumentPresentation() + typing.synchronizeEnglishDocumentContext( + caretMoved: true, + presentationID: firstPresentation + ) + XCTAssertTrue(typing.composition.preedit.isEmpty) + XCTAssertTrue(typing.composition.candidates.isEmpty) + + typing.synchronizeEnglishDocumentContext( + caretMoved: true, + presentationID: secondPresentation + ) + XCTAssertEqual(typing.composition.preedit.lowercased(), "wor") + XCTAssertFalse(typing.composition.candidates.isEmpty) + } + + @MainActor + func testEndingDocumentPresentationClearsLocalEnglishShadow() { + var preceding = "" + let typing = makeTypingSession() + typing.precedingTextProvider = { preceding } + typing.followingTextProvider = { "" } + typing.enterTypingMode() + _ = typing.beginDocumentPresentation() + + _ = typing.handleKey("h") + XCTAssertEqual(typing.composition.preedit.lowercased(), "h") + + typing.endDocumentPresentation() + preceding = "new" + let nextPresentation = typing.beginDocumentPresentation() + typing.synchronizeEnglishDocumentContext( + caretMoved: true, + presentationID: nextPresentation + ) + + XCTAssertEqual(typing.composition.preedit.lowercased(), "new") + } + + @MainActor + func testDisablingSuggestionsImmediatelyClearsEnglishCandidates() { + let typing = makeTypingSession() + typing.enterTypingMode() + _ = typing.handleKey("h") + XCTAssertFalse(typing.composition.candidates.isEmpty) + + typing.suggestionsEnabled = false + + XCTAssertTrue(typing.composition.preedit.isEmpty) + XCTAssertTrue(typing.composition.candidates.isEmpty) + } + func testEnglishTypingHotwordsFilterOutChineseTerms() { var dictionary = PersonalDictionary() _ = dictionary.upsertManual(term: "张三") @@ -541,6 +614,70 @@ final class EnglishTypingTests: XCTestCase { } } + @MainActor + func testRepeatedRareEnglishCommitBecomesDictionarySuggestion() { + let suite = "english.frequent.term.test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let frequentStore = FrequentTermStore(defaults: defaults) + let typing = makeTypingSession(frequentTermStore: frequentStore) + typing.enterTypingMode() + + for _ in 0..<2 { + for character in "OpenAI" { + _ = typing.handleKey(String(character)) + } + let output = typing.handleSpace() + XCTAssertEqual(output.deleteCount, 0) + } + + let storedData = defaults.data(forKey: FrequentTermStore.defaultsKey) + let storedTerms = storedData.flatMap { try? JSONDecoder().decode([FrequentTerm].self, from: $0) } + XCTAssertEqual(storedTerms?.first?.commitCount, 2) + let suggestion = frequentStore.suggestions(excludingPersonalTerms: []).first + XCTAssertEqual(suggestion?.term, "OpenAI") + XCTAssertEqual(suggestion?.commitCount, 2) + } + + @MainActor + func testCommonEnglishCommitDoesNotBecomeDictionarySuggestion() { + let suite = "english.frequent.common.test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let frequentStore = FrequentTermStore(defaults: defaults) + let typing = makeTypingSession(frequentTermStore: frequentStore) + typing.enterTypingMode() + + for _ in 0..<2 { + for character in "the" { + _ = typing.handleKey(String(character)) + } + _ = typing.handleSpace() + } + + XCTAssertTrue(frequentStore.suggestions(excludingPersonalTerms: []).isEmpty) + } + + @MainActor + func testSecureEnglishCommitDoesNotBecomeDictionarySuggestion() { + let suite = "english.frequent.secure.test.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + let frequentStore = FrequentTermStore(defaults: defaults) + let typing = makeTypingSession(frequentTermStore: frequentStore) + typing.enterTypingMode() + typing.suggestionsEnabled = false + + for _ in 0..<2 { + for character in "OpenAI" { + _ = typing.handleKey(String(character)) + } + _ = typing.handleSpace() + } + + XCTAssertTrue(frequentStore.suggestions(excludingPersonalTerms: []).isEmpty) + } + func testQWERTYNeighborsIncludeDiagonals() { let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true) XCTAssertTrue(aroundG.contains("t")) @@ -570,6 +707,7 @@ final class EnglishTypingTests: XCTestCase { @MainActor private func makeTypingSession( learningStore: EnglishLearningStore? = nil, + frequentTermStore: FrequentTermStore? = nil, language: TypingInputLanguage = .english ) -> TypingSessionController { let store = learningStore ?? EnglishLearningStore( @@ -577,7 +715,10 @@ final class EnglishTypingTests: XCTestCase { ) let typing = TypingSessionController( engine: { NoopRimeEngine() }, - learningStore: store + learningStore: store, + frequentTermStore: frequentTermStore ?? FrequentTermStore( + defaults: UserDefaults(suiteName: "frequent.tests.\(UUID().uuidString)")! + ) ) typing.suggestionsEnabled = true _ = typing.setLanguage(language) diff --git a/OSGKeyboardExtTests/KeyboardStateTests.swift b/OSGKeyboardExtTests/KeyboardStateTests.swift index 4823401..63ec86d 100644 --- a/OSGKeyboardExtTests/KeyboardStateTests.swift +++ b/OSGKeyboardExtTests/KeyboardStateTests.swift @@ -36,6 +36,7 @@ final class KeyboardStateTests: XCTestCase { XCTAssertEqual(s.level, 0) XCTAssertFalse(s.onDeviceSupported) XCTAssertFalse(s.undoAvailable) + XCTAssertFalse(s.hasFullAccess) } func testPhaseTransitionsIdleToRequestingPermissionsAndBack() { @@ -145,6 +146,20 @@ final class KeyboardStateTests: XCTestCase { XCTAssertEqual(state.clipboardOverlay, .none) } + func testClipboardFullAccessHintTracksCaptureAvailability() { + let state = KeyboardState() + state.clipboardHistoryEnabled = true + + XCTAssertTrue(state.needsClipboardFullAccessHint) + + state.hasFullAccess = true + XCTAssertFalse(state.needsClipboardFullAccessHint) + + state.hasFullAccess = false + state.clipboardHistoryEnabled = false + XCTAssertFalse(state.needsClipboardFullAccessHint) + } + func testResolvedSkillCopyPublishesWhenIDsStayStable() { let state = KeyboardState() state.enabledClipboardSkillIDs = ["official.rewrite"] diff --git a/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift b/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift new file mode 100644 index 0000000..9d8cc58 --- /dev/null +++ b/OSGKeyboardHostSupport/DesignSystem/MonthlyUsageCalendar.swift @@ -0,0 +1,215 @@ +// MonthlyUsageCalendar.swift +// OSGKeyboard · HostSupport +// +// Current-month dictation heat map. Every date remains visible; the accent +// circle's opacity communicates that day's character count relative to the +// busiest day in the same month. + +import SwiftUI +#if canImport(OSGKeyboardShared) +import OSGKeyboardShared +#endif + +public struct MonthlyUsageCalendar: View { + @Environment(\.themePalette) private var palette + + public let points: [UsageStatisticsStore.DailyUsagePoint] + public let language: AppUILanguage + public var compact: Bool + public var embedsInCard: Bool + + public init( + points: [UsageStatisticsStore.DailyUsagePoint], + language: AppUILanguage, + compact: Bool = false, + embedsInCard: Bool = true + ) { + self.points = points + self.language = language + self.compact = compact + self.embedsInCard = embedsInCard + } + + private var displayLocale: Locale { + Locale(identifier: language.resolvedLanguageCode()) + } + + private var displayCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.locale = displayLocale + return calendar + } + + private var total: Int { + points.reduce(0) { $0 + $1.value } + } + + private var maximumValue: Int { + max(points.map(\.value).max() ?? 0, 1) + } + + private var monthTitle: String { + guard let date = points.first?.date else { return "" } + return date.formatted( + .dateTime + .year() + .month(.wide) + .locale(displayLocale) + ) + } + + private var weekdaySymbols: [String] { + let symbols = displayCalendar.veryShortStandaloneWeekdaySymbols + let startIndex = max(0, min(displayCalendar.firstWeekday - 1, symbols.count - 1)) + return Array(symbols[startIndex...] + symbols[.. some View { + let calendar = displayCalendar + let day = calendar.component(.day, from: point.date) + let isToday = calendar.isDateInToday(point.date) + let isFuture = calendar.startOfDay(for: point.date) > calendar.startOfDay(for: Date()) + let opacity = fillOpacity(for: point.value, isFuture: isFuture) + let usesLightText = opacity >= 0.48 + + return Text(day.formatted()) + .font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded)) + .foregroundStyle( + isFuture + ? palette.textTertiary.opacity(0.45) + : (usesLightText ? palette.textOnAccent : palette.textPrimary) + ) + .frame(width: cellDiameter, height: cellDiameter) + .background(palette.accent.opacity(opacity), in: Circle()) + .overlay { + if isToday { + Circle() + .stroke(palette.accent, lineWidth: 1.5) + } + } + .frame(maxWidth: .infinity) + .accessibilityElement() + .accessibilityLabel( + Text(point.date.formatted(.dateTime.month().day())) + ) + .accessibilityValue( + Text(UsageStatisticsStore.formatCount(point.value, language: language)) + ) + } + + private func fillOpacity(for value: Int, isFuture: Bool) -> Double { + guard value > 0, !isFuture else { return 0 } + let ratio = min(max(Double(value) / Double(maximumValue), 0), 1) + return 0.14 + sqrt(ratio) * 0.62 + } +} + +#Preview("Monthly usage") { + let calendar = Calendar.current + let start = calendar.date( + from: calendar.dateComponents([.year, .month], from: Date()) + ) ?? Date() + let points = (0..<31).compactMap { offset -> UsageStatisticsStore.DailyUsagePoint? in + guard let date = calendar.date(byAdding: .day, value: offset, to: start) else { + return nil + } + let value = offset.isMultiple(of: 5) ? offset * 120 : 0 + return UsageStatisticsStore.DailyUsagePoint(date: date, value: value) + } + + ThemedRoot { + MonthlyUsageCalendar( + points: points, + language: .auto + ) + .padding() + } +} diff --git a/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift b/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift index b31c5bc..e9a8f67 100644 --- a/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift +++ b/OSGKeyboardHostSupport/DesignSystem/UsageStatsCluster.swift @@ -1,11 +1,11 @@ // UsageStatsCluster.swift // OSGKeyboard · HostSupport // -// Cross-platform home / dashboard stats: 7-day chart + cumulative metrics. +// Cross-platform home / dashboard stats: monthly calendar + cumulative metrics. // Callers observe their store and pass plain values — Shared stays unbound // from platform singletons. // -// Optional `header` sits above the 7-day chart inside the same surface card +// Optional `header` sits above the monthly calendar inside the same surface card // (iOS Home glass preview field). Mac / plain call sites keep `EmptyView`. import SwiftUI @@ -14,15 +14,21 @@ import OSGKeyboardShared #endif public enum UsageStatsClusterLayout: Sendable, Equatable { - /// Chart left, 2×2 `UsageStatCard` grid right (Mac / iPad). + /// Monthly calendar left, 2×2 `UsageStatCard` grid right (Mac / iPad). case split - /// Chart above a compact single-card 2×2 grid (iPhone). + /// Monthly calendar above a compact single-card 2×2 grid (iPhone). case stacked /// 手机端 2×2 统计网格的紧凑固定高度(沿用旧版 HomeStatsCard 数值)。 public static let compactGridHeight: CGFloat = 166 } +public enum UsageStatsClusterContent: Sendable, Equatable { + case all + case calendar + case metrics +} + public struct UsageStatsCluster: View { @Environment(\.themePalette) private var palette @@ -33,9 +39,12 @@ public struct UsageStatsCluster: View { public let dictationDurationSeconds: TimeInterval public let translationCharacterCount: Int public let dictionaryTermCount: Int - /// 小屏(如 iPhone SE)收紧 stacked 图表高度,把空间让给下方的输入框。 + /// 小屏(如 iPhone SE)收紧日期圆形尺寸,把空间让给下方内容。 public let compact: Bool + public let content: UsageStatsClusterContent private let header: Header + private let onOpenHistory: (() -> Void)? + private let onOpenDictionary: (() -> Void)? public init( layout: UsageStatsClusterLayout, @@ -46,6 +55,9 @@ public struct UsageStatsCluster: View { translationCharacterCount: Int, dictionaryTermCount: Int, compact: Bool = false, + content: UsageStatsClusterContent = .all, + onOpenHistory: (() -> Void)? = nil, + onOpenDictionary: (() -> Void)? = nil, @ViewBuilder header: () -> Header ) { self.layout = layout @@ -56,22 +68,33 @@ public struct UsageStatsCluster: View { self.translationCharacterCount = translationCharacterCount self.dictionaryTermCount = dictionaryTermCount self.compact = compact + self.content = content + self.onOpenHistory = onOpenHistory + self.onOpenDictionary = onOpenDictionary self.header = header() } + @ViewBuilder public var body: some View { - switch layout { - case .split: - splitBody - case .stacked: - stackedBody + switch content { + case .all: + switch layout { + case .split: + splitBody + case .stacked: + stackedBody + } + case .calendar: + chartCard + case .metrics: + metricsBody } } // MARK: - Split (Mac / iPad) private var splitBody: some View { - HStack(alignment: .top, spacing: Spacing.md) { + HStack(alignment: .top, spacing: splitSectionSpacing) { chartCard .frame(maxWidth: .infinity, maxHeight: .infinity) splitStatGrid @@ -81,126 +104,200 @@ public struct UsageStatsCluster: View { } private var splitStatGrid: some View { - VStack(spacing: Spacing.md) { - HStack(spacing: Spacing.md) { - UsageStatCard( + VStack(spacing: splitItemSpacing) { + HStack(spacing: splitItemSpacing) { + splitCell( title: SharedL10n.string("stat.words", language: language), value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), caption: SharedL10n.string("stat.transcribed", language: language), systemImage: "text.alignleft", - accent: true + accent: true, + action: onOpenHistory ) - UsageStatCard( + splitCell( title: SharedL10n.string("stat.dictationTime", language: language), value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), caption: SharedL10n.string("stat.cumulativeDuration", language: language), systemImage: "waveform" ) } - HStack(spacing: Spacing.md) { - UsageStatCard( + HStack(spacing: splitItemSpacing) { + splitCell( title: SharedL10n.string("stat.translation", language: language), value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), caption: SharedL10n.string("stat.cumulativeTranslation", language: language), systemImage: "character.bubble" ) - UsageStatCard( + splitCell( title: SharedL10n.string("stat.dictionary", language: language), value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), caption: SharedL10n.string("stat.customTerms", language: language), - systemImage: "character.book.closed" + systemImage: "character.book.closed", + action: onOpenDictionary ) } } } + @ViewBuilder + private var metricsBody: some View { + switch layout { + case .split: + splitStatGrid + case .stacked: + compactStatGrid + } + } + + @ViewBuilder + private func splitCell( + title: String, + value: String, + caption: String, + systemImage: String, + accent: Bool = false, + action: (() -> Void)? = nil + ) -> some View { + if let action { + Button(action: action) { + UsageStatCard( + title: title, + value: value, + caption: caption, + accent: accent + ) + .overlay(alignment: .topTrailing) { + disclosureIndicator + .padding(Spacing.md) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .frame(maxWidth: .infinity) + } else { + UsageStatCard( + title: title, + value: value, + caption: caption, + systemImage: systemImage, + accent: accent + ) + } + } + // MARK: - Stacked (iPhone) private var stackedBody: some View { - VStack(spacing: compact ? Spacing.sm : Spacing.md) { + VStack(spacing: CardLayoutMetrics.sectionSpacing) { chartCard compactStatGrid } } - /// Chart surface; when `header` is present it sits above the bars in the same card. + /// Calendar surface; when `header` is present it sits above the dates in the same card. @ViewBuilder private var chartCard: some View { if Header.self == EmptyView.self { - SevenDayUsageChart( + MonthlyUsageCalendar( points: points, language: language, - chartMinHeight: compact ? 72 : 96, - expands: layout == .split + compact: compact ) } else { UsageSurfaceCard(padding: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.sm) { header - SevenDayUsageChart( + MonthlyUsageCalendar( points: points, language: language, - chartMinHeight: compact ? 72 : 96, - embedsInCard: false, - expands: layout == .split + compact: compact, + embedsInCard: false ) } } } } - /// Phone-friendly 2×2: value + label only, single card with hairline dividers. + /// Phone-friendly 2×2: each metric has its own compact surface card. private var compactStatGrid: some View { - VStack(spacing: 0) { - HStack(spacing: 0) { + VStack(spacing: CardLayoutMetrics.compactItemSpacing) { + HStack(spacing: CardLayoutMetrics.compactItemSpacing) { compactCell( - systemImage: "waveform", + systemImage: "waveform.badge.microphone", value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), label: SharedL10n.string("stat.dictationTime", language: language) ) - compactDivider compactCell( - systemImage: "text.alignleft", + systemImage: "text.quote", value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), - label: SharedL10n.string("stat.words", language: language) + label: SharedL10n.string("stat.words", language: language), + action: onOpenHistory ) } - Rectangle() - .fill(palette.divider) - .frame(height: 0.5) - HStack(spacing: 0) { + .frame(maxHeight: .infinity) + HStack(spacing: CardLayoutMetrics.compactItemSpacing) { compactCell( - systemImage: "character.bubble", + systemImage: "translate", value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), label: SharedL10n.string("stat.translation", language: language) ) - compactDivider compactCell( - systemImage: "character.book.closed", + systemImage: "books.vertical", value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), - label: SharedL10n.string("stat.dictionary", language: language) + label: SharedL10n.string("stat.dictionary", language: language), + action: onOpenDictionary ) } + .frame(maxHeight: .infinity) } // 锁定紧凑固定高度(对齐旧版 HomeStatsCard 的 166pt),避免格子按内容撑高。 .frame(height: UsageStatsClusterLayout.compactGridHeight) - .background(palette.surface) - .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } - private var compactDivider: some View { - Rectangle() - .fill(palette.divider) - .frame(width: 0.5) + @ViewBuilder + private func compactCell( + systemImage: String, + value: String, + label: String, + action: (() -> Void)? = nil + ) -> some View { + if let action { + Button(action: action) { + compactCellContent( + systemImage: systemImage, + value: value, + label: label, + showsDisclosure: true + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } else { + compactCellContent( + systemImage: systemImage, + value: value, + label: label, + showsDisclosure: false + ) + } } - private func compactCell(systemImage: String, value: String, label: String) -> some View { - HStack(alignment: .top, spacing: Spacing.xs) { - VStack(alignment: .leading, spacing: Spacing.xs) { + private func compactCellContent( + systemImage: String, + value: String, + label: String, + showsDisclosure: Bool + ) -> some View { + let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + return ZStack(alignment: .bottomTrailing) { + // Subtle watermark stays fully visible within the card edges. + Image(systemName: systemImage) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(palette.textPrimary.opacity(0.06)) + .frame(width: 32, height: 32, alignment: .center) + .offset(x: Spacing.xs, y: Spacing.xs) + + VStack(alignment: .leading, spacing: Spacing.xxs) { Text(value) .font(.system(size: 24, weight: .semibold, design: .rounded)) .foregroundStyle(palette.textPrimary) @@ -214,14 +311,41 @@ public struct UsageStatsCluster: View { .lineLimit(1) .minimumScaleFactor(0.85) } - Spacer(minLength: Spacing.xs) - Image(systemName: systemImage) - .font(.system(size: 18, weight: .semibold)) - .foregroundStyle(palette.accent) - .padding(.top, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(Spacing.md) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(Spacing.sm) + .background(palette.surface, in: shape) + .overlay(alignment: .topTrailing) { + if showsDisclosure { + disclosureIndicator + .padding(Spacing.sm) + } + } + .clipShape(shape) + } + + private var disclosureIndicator: some View { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .accessibilityHidden(true) + } + + private var splitSectionSpacing: CGFloat { + #if os(macOS) + Spacing.md + #else + CardLayoutMetrics.sectionSpacing + #endif + } + + private var splitItemSpacing: CGFloat { + #if os(macOS) + Spacing.md + #else + CardLayoutMetrics.compactItemSpacing + #endif } } @@ -234,7 +358,10 @@ extension UsageStatsCluster where Header == EmptyView { dictationDurationSeconds: TimeInterval, translationCharacterCount: Int, dictionaryTermCount: Int, - compact: Bool = false + compact: Bool = false, + content: UsageStatsClusterContent = .all, + onOpenHistory: (() -> Void)? = nil, + onOpenDictionary: (() -> Void)? = nil ) { self.init( layout: layout, @@ -245,6 +372,9 @@ extension UsageStatsCluster where Header == EmptyView { translationCharacterCount: translationCharacterCount, dictionaryTermCount: dictionaryTermCount, compact: compact, + content: content, + onOpenHistory: onOpenHistory, + onOpenDictionary: onOpenDictionary, header: { EmptyView() } ) } diff --git a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift index f46c7c5..9bf0b52 100644 --- a/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift +++ b/OSGKeyboardHostSupport/Features/Account/HostPrivateAccountKeychain.swift @@ -4,6 +4,7 @@ // Main-app-only storage for OSG account sessions and App Attest key state. import Foundation +import OSLog import Security public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable { @@ -42,6 +43,11 @@ public actor HostPrivateAccountKeychain: AccountSessionVault, AppAttestKeyStateStoring, OOBEInstallationIDStoring { + private static let logger = Logger( + subsystem: HostPrivateAccountKeychainDescriptor.hostBundleIdentifier, + category: "account" + ) + private enum Account { static let session = "account.session" static let appAttestKeyState = "integrity.app-attest-key-state" @@ -59,7 +65,16 @@ public actor HostPrivateAccountKeychain: } public func loadSession() async throws -> AccountSession? { - try read(AccountSession.self, account: Account.session) + do { + let session = try read(AccountSession.self, account: Account.session) + Self.logger.info( + "session keychain restore status=\(session == nil ? "not-found" : "found", privacy: .public)" + ) + return session + } catch { + Self.logger.error("session keychain restore status=unavailable") + throw error + } } public func saveSession(_ session: AccountSession) async throws { @@ -111,6 +126,9 @@ public actor HostPrivateAccountKeychain: case errSecItemNotFound: return nil default: + Self.logger.error( + "keychain read failed account=\(account, privacy: .public) status=\(status, privacy: .public)" + ) throw AccountAPIError.secureStorage } } diff --git a/OSGKeyboardMac/DashboardView.swift b/OSGKeyboardMac/DashboardView.swift index eb3f239..f79d6dd 100644 --- a/OSGKeyboardMac/DashboardView.swift +++ b/OSGKeyboardMac/DashboardView.swift @@ -88,13 +88,13 @@ struct DashboardView: View { .frame(maxWidth: .infinity, alignment: .leading) } - // MARK: - Stats (7-day chart + cumulative grid — shared cluster) + // MARK: - Stats (monthly calendar + cumulative grid — shared cluster) private var statCluster: some View { UsageStatsCluster( layout: .split, language: lang, - points: stats.last7Days, + points: stats.currentMonth, dictationCharacterCount: stats.dictationCharacterCount, dictationDurationSeconds: stats.dictationDurationSeconds, translationCharacterCount: stats.translationCharacterCount, @@ -225,10 +225,6 @@ struct BottomDictationBar: View { palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) ) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } .menuStyle(.borderlessButton) .fixedSize() diff --git a/OSGKeyboardMac/MacComponents.swift b/OSGKeyboardMac/MacComponents.swift index c06476e..187a4fc 100644 --- a/OSGKeyboardMac/MacComponents.swift +++ b/OSGKeyboardMac/MacComponents.swift @@ -133,7 +133,7 @@ private struct MacGlassSurface: ViewModifier { // Flat, shadowless surface fill. We deliberately avoid `glassEffect` // here: on macOS 26 Liquid Glass adds a raised drop shadow to every // card, which reads as visual noise for content containers. Hierarchy - // is carried by the surface colour + hairline border instead. + // is carried by the semantic surface colour instead. content .background(palette.surface.opacity(fillOpacity), in: shape) } @@ -361,10 +361,6 @@ struct MacSettingsIconButton: View { .foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary) .frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight) .background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } .buttonStyle(.plain) .disabled(disabled) @@ -381,9 +377,6 @@ struct MacSettingsToolButton: View { var fill: Color? /// Text color. Defaults to primary text (tertiary when disabled). var foreground: Color? - /// Hairline divider border — drawn for the neutral variant, hidden for - /// colored fills (delete / download) so the fill reads as the button. - var showsBorder: Bool = true var disabled: Bool = false let action: () -> Void @@ -396,11 +389,6 @@ struct MacSettingsToolButton: View { .padding(.horizontal, Spacing.md) .frame(minHeight: 34) .background(fill ?? palette.surfaceElevated, in: shape) - .overlay { - if showsBorder { - shape.stroke(palette.divider, lineWidth: 0.5) - } - } } .buttonStyle(.plain) .disabled(disabled) @@ -485,7 +473,6 @@ extension MacPageHeader where Trailing == EmptyView { /// Elevated surface used for stat tiles and the dictation canvas. struct MacCard: View { - @Environment(\.themePalette) private var palette var padding: CGFloat = Spacing.md var cornerRadius: CGFloat = Radius.medium @ViewBuilder var content: () -> Content @@ -496,10 +483,6 @@ struct MacCard: View { content() .padding(padding) .macGlassSurface(in: shape, fillOpacity: 1) - .overlay( - shape - .stroke(palette.divider, lineWidth: 0.5) - ) } } diff --git a/OSGKeyboardMac/MacDictionaryView.swift b/OSGKeyboardMac/MacDictionaryView.swift index a2ea3e3..3e6598f 100644 --- a/OSGKeyboardMac/MacDictionaryView.swift +++ b/OSGKeyboardMac/MacDictionaryView.swift @@ -14,8 +14,6 @@ struct MacDictionaryView: View { @State private var showEntryEditor = false @State private var generatingAliasEntryIDs: Set = [] - private let aliasGenerator = DictionaryAliasGenerator() - private var lang: AppUILanguage { viewModel.config.uiLanguage } private var entries: [PersonalDictionary.Entry] { @@ -235,29 +233,22 @@ struct MacDictionaryView: View { private func saveManualEntry(term: String) { let store = AppGroupStore(defaults: viewModel.defaults) - var dictionary = store.personalDictionary - guard let saved = dictionary.upsertManual(term: term) else { return } - dictionary.version += 1 - store.setPersonalDictionary(dictionary) + let service = PersonalDictionaryEntryService( + store: store, + cloudPush: { dictionary in + try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary) + } + ) + guard let saved = service.saveEntry(term: term, source: .manual) else { return } viewModel.refreshDictionaryFromCloud() - generatingAliasEntryIDs.insert(saved.id) + if saved.shouldGenerateAliases { + generatingAliasEntryIDs.insert(saved.entryID) + } Task { - try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary) - let aliases = await aliasGenerator.generateAliases(for: saved.term) - - generatingAliasEntryIDs.remove(saved.id) - guard !aliases.isEmpty else { return } - - var latest = store.personalDictionary - guard latest.entries.contains(where: { - $0.id == saved.id && $0.term == saved.term - }) else { return } - latest.updateAliases(for: saved.id, aliases: aliases) - latest.version += 1 - store.setPersonalDictionary(latest) + await service.finishSaving(saved) + generatingAliasEntryIDs.remove(saved.entryID) viewModel.refreshDictionaryFromCloud() - try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(latest) } } } diff --git a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift index 65ab5b4..4e7646d 100644 --- a/OSGKeyboardMac/MacLocalASRModelSettingsView.swift +++ b/OSGKeyboardMac/MacLocalASRModelSettingsView.swift @@ -377,8 +377,7 @@ struct MacLocalASRModelSettingsView: View { MacSettingsToolButton( title: MacL10n.string("mac.localASR.delete", language: lang), fill: palette.danger.opacity(0.15), - foreground: palette.danger, - showsBorder: false + foreground: palette.danger ) { modelVM.deleteModel(model) } @@ -386,8 +385,7 @@ struct MacLocalASRModelSettingsView: View { MacSettingsToolButton( title: MacL10n.string("mac.localASR.download", language: lang), fill: palette.accent, - foreground: .white, - showsBorder: false + foreground: .white ) { modelVM.installModel(model) } diff --git a/OSGKeyboardMac/MacOnboardingView.swift b/OSGKeyboardMac/MacOnboardingView.swift index 94e04f9..20e4507 100644 --- a/OSGKeyboardMac/MacOnboardingView.swift +++ b/OSGKeyboardMac/MacOnboardingView.swift @@ -350,7 +350,6 @@ struct MacOnboardingView: View { .padding(.horizontal, Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) - .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) } private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View { @@ -368,7 +367,6 @@ struct MacOnboardingView: View { .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) - .overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1)) } private var enginePicker: some View { @@ -423,7 +421,6 @@ struct MacOnboardingView: View { .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(selected ? palette.accentMuted : palette.surface)) - .overlay(cardShape.stroke(selected ? palette.accent.opacity(0.5) : palette.divider, lineWidth: selected ? 1 : 0.5)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -461,7 +458,6 @@ struct MacOnboardingView: View { .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) - .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) } private var localModelPanel: some View { @@ -513,7 +509,6 @@ struct MacOnboardingView: View { .padding(Spacing.md) .frame(maxWidth: .infinity) .background(cardShape.fill(palette.surface)) - .overlay(cardShape.stroke(palette.divider, lineWidth: 0.5)) } // MARK: Progress dots @@ -577,8 +572,8 @@ struct MacOnboardingView: View { .padding(.horizontal, Spacing.lg) .frame(minHeight: 44) .background( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.dividerStrong, lineWidth: 0.5) + palette.surfaceElevated, + in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) ) } .buttonStyle(.plain) diff --git a/OSGKeyboardMac/MacPolishStylesView.swift b/OSGKeyboardMac/MacPolishStylesView.swift index 7e7a2a3..5544a64 100644 --- a/OSGKeyboardMac/MacPolishStylesView.swift +++ b/OSGKeyboardMac/MacPolishStylesView.swift @@ -403,12 +403,6 @@ private struct MacPolishStyleCard: View { isSelected ? palette.accentMuted : palette.surface, in: shape ) - .overlay( - shape.stroke( - isSelected ? palette.accent : hoverBorder, - lineWidth: isSelected ? 1.5 : 0.5 - ) - ) .clipShape(shape) .scaleEffect(isHovering ? 1.01 : 1) .animation(Motion.quick, value: isHovering) @@ -421,10 +415,6 @@ private struct MacPolishStyleCard: View { } } } - - private var hoverBorder: Color { - isHovering ? palette.dividerStrong : palette.divider - } } /// Read-only prompt viewer for built-in styles (mirrors iOS). diff --git a/OSGKeyboardShared/DesignSystem/CardPageLayout.swift b/OSGKeyboardShared/DesignSystem/CardPageLayout.swift index 045109a..f6f8d70 100644 --- a/OSGKeyboardShared/DesignSystem/CardPageLayout.swift +++ b/OSGKeyboardShared/DesignSystem/CardPageLayout.swift @@ -13,7 +13,7 @@ public struct CardPageContent: View { private let content: Content public init( - spacing: CGFloat = Spacing.md, + spacing: CGFloat = CardLayoutMetrics.sectionSpacing, topPadding: CGFloat = Spacing.md, bottomPadding: CGFloat = Spacing.md, @ViewBuilder content: () -> Content @@ -95,12 +95,8 @@ public struct SurfaceCardModifier: ViewModifier { in: shape ) // Clip child backgrounds as well as the card surface. Without - // this, a full-width child can visually square off a corner - // even though the shared background and border use Radius.xl. + // this, a full-width child can visually square off a corner. .clipShape(shape) - .overlay( - shape.stroke(palette.divider, lineWidth: 0.5) - ) } else { content } diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index 44b8e25..757b679 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -158,6 +158,14 @@ public enum Spacing { public static let hero: CGFloat = 48 } +/// Semantic spacing for sibling card surfaces. +public enum CardLayoutMetrics { + /// Vertical gap between page-level cards or card sections. + public static let sectionSpacing: CGFloat = 18 + /// Horizontal and vertical gap between compact cards or list-item cards. + public static let compactItemSpacing: CGFloat = Spacing.xs +} + // MARK: - Corner radius scale public enum Radius { @@ -227,10 +235,6 @@ private struct CardSurfaceModifier: ViewModifier { content .padding(padding) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) } } @@ -267,10 +271,6 @@ private struct SecondaryButtonModifier: ViewModifier { .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.dividerStrong, lineWidth: 0.5) - ) .foregroundStyle(palette.textPrimary) } } diff --git a/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift index b6afd96..2c7de33 100644 --- a/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift +++ b/OSGKeyboardShared/DesignSystem/UsageSurfaceCard.swift @@ -2,7 +2,7 @@ // OSGKeyboard · Shared // // Flat semantic surface used by home / dashboard stats on every platform. -// Deliberately shadowless — hierarchy comes from fill + hairline border. +// Deliberately shadowless — hierarchy comes from the semantic surface fill. import SwiftUI @@ -30,8 +30,5 @@ public struct UsageSurfaceCard: View { .padding(padding) .background(palette.surface, in: shape) .clipShape(shape) - .overlay( - shape.stroke(palette.divider, lineWidth: 0.5) - ) } } diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift index aa14679..e4d4429 100644 --- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift +++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift @@ -105,6 +105,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { public var uiLanguage: AppUILanguage public var translationTargetLocaleId: String public var handednessPreference: HandednessPreference + /// Decode-only compatibility field for cursor drag pads removed in 2.0.0. public var cursorDragNavigationEnabled: Bool /// Typing-grid haptic strength (off / light / strong). public var keyboardHapticIntensity: KeyboardHapticIntensity @@ -300,7 +301,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { ), cursorDragNavigationEnabled: { if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil { - return true + return false } return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) }(), @@ -594,17 +595,7 @@ public struct AppGroupConfiguration: Sendable, Equatable { return .empty } do { - var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data) - if dictionary.entries.contains(where: { $0.source == .history }) { - for index in dictionary.entries.indices where dictionary.entries[index].source == .history { - dictionary.entries[index].source = .manual - } - dictionary.version += 1 - if let migrated = try? JSONEncoder().encode(dictionary) { - defaults.set(migrated, forKey: Keys.personalDictionary) - } - } - return dictionary + return try JSONDecoder().decode(PersonalDictionary.self, from: data) } catch { OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)") return .empty diff --git a/OSGKeyboardShared/Models/KeyboardChromeLayout.swift b/OSGKeyboardShared/Models/KeyboardChromeLayout.swift index a37a238..564a12b 100644 --- a/OSGKeyboardShared/Models/KeyboardChromeLayout.swift +++ b/OSGKeyboardShared/Models/KeyboardChromeLayout.swift @@ -39,12 +39,11 @@ public enum KeyboardChromeLayout { public static let horizontalInset: CGFloat = 8 /// Voice-surface content column cap. /// - /// The voice surface is a sparse cluster — two cursor-drag pads flanking a - /// fixed 121 pt mic — over a transparent background, so filling an iPad's - /// width buys no visual width; it only parks delete/return at the screen - /// edges and turns each drag pad into a ~450 pt runway. The typing surface - /// has the opposite need (a key grid must fill the width to match the - /// system keyboard), which is why it no longer shares this constant. + /// The voice surface is a sparse control cluster over a transparent + /// background, so filling an iPad's width buys no visual width and only + /// parks delete/return at the screen edges. The typing surface has the + /// opposite need (a key grid must fill the width to match the system + /// keyboard), which is why it no longer shares this constant. public static let voiceContentMaxWidth: CGFloat = 700 /// Width at or above which the typing surface switches to wide-iPad diff --git a/OSGKeyboardShared/Models/PersonalDictionary.swift b/OSGKeyboardShared/Models/PersonalDictionary.swift index 6272c73..004381f 100644 --- a/OSGKeyboardShared/Models/PersonalDictionary.swift +++ b/OSGKeyboardShared/Models/PersonalDictionary.swift @@ -8,7 +8,7 @@ // // Sources (mutually exclusive per entry): // - `.manual` user typed it in by hand -// - `.history` legacy auto-learned entries (migrated to `.manual`) +// - `.history` user-confirmed recommendations from typing history // - `.contacts` imported from the iOS Contacts framework // - `.recentEdit` extracted from edits the user made to a // polished transcript before sending @@ -274,12 +274,28 @@ extension PersonalDictionary { return entries.first { $0.term.lowercased() == key } } - /// Insert or update a manual entry. Returns the saved entry. + /// Insert or update a manual entry. Kept as a convenience for existing callers. @discardableResult public mutating func upsertManual( term: String, existingID: UUID? = nil, regenerateAliases: Bool = false + ) -> Entry? { + upsert( + term: term, + existingID: existingID, + source: .manual, + regenerateAliases: regenerateAliases + ) + } + + /// Insert or update an entry while preserving its user-visible origin. + @discardableResult + public mutating func upsert( + term: String, + existingID: UUID? = nil, + source: Entry.Source, + regenerateAliases: Bool = false ) -> Entry? { let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } @@ -292,7 +308,7 @@ extension PersonalDictionary { let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame entry.term = trimmed entry.category = category - entry.source = .manual + entry.source = source if termChanged || regenerateAliases { entry.aliases = [] } @@ -307,7 +323,7 @@ extension PersonalDictionary { var entry = entries[idx] entry.term = trimmed entry.category = category - entry.source = .manual + entry.source = source entry.updatedAt = Date() entries[idx] = entry return entry @@ -318,7 +334,7 @@ extension PersonalDictionary { term: trimmed, aliases: [], category: category, - source: .manual, + source: source, createdAt: now, updatedAt: now ) diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 676aa92..22b40ba 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -113,7 +113,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { didSet { guard !isApplyingConfiguration, localeId != configuration.localeId else { return } configuration.localeId = localeId - persistConfiguration() + persistConfiguration(postConfigChanged: true) } } /// "local" → on-device ASR + user's LLM polish (requires user API key). @@ -220,16 +220,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { } } - /// Press-and-drag pads beside the mic for four-way caret movement. - @Published public var cursorDragNavigationEnabled: Bool { - didSet { - guard !isApplyingConfiguration, - cursorDragNavigationEnabled != configuration.cursorDragNavigationEnabled else { return } - configuration.cursorDragNavigationEnabled = cursorDragNavigationEnabled - persistConfiguration(postConfigChanged: true) - } - } - /// Typing-grid haptic strength (off / light / strong). Default is light. @Published public var keyboardHapticIntensity: KeyboardHapticIntensity { didSet { @@ -445,7 +435,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { uiLanguage = configuration.uiLanguage translationTargetLocaleId = configuration.translationTargetLocaleId handednessPreference = configuration.handednessPreference - cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled keyboardHapticIntensity = configuration.keyboardHapticIntensity polishIntensity = configuration.polishIntensity aiResponseLength = configuration.aiResponseLength @@ -551,7 +540,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { uiLanguage = fresh.uiLanguage translationTargetLocaleId = fresh.translationTargetLocaleId handednessPreference = fresh.handednessPreference - cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled keyboardHapticIntensity = fresh.keyboardHapticIntensity polishIntensity = fresh.polishIntensity aiResponseLength = fresh.aiResponseLength diff --git a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift index cb2c7fb..4448bbc 100644 --- a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift +++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift @@ -70,6 +70,18 @@ public final class AIAgentSkillLayoutStore: ObservableObject { mergedCatalog.filter { !layout.isEnabled($0.id) } } + public var skillManagementEnabledSkills: [AIClipboardSkill] { + enabledSkills.filter { + !AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id) + } + } + + public var skillManagementAvailableSkills: [AIClipboardSkill] { + availableSkills.filter { + !AIClipboardSkillCatalog.hiddenFromSkillManagementIDs.contains($0.id) + } + } + public func userSkill(id: String) -> AIUserSkill? { userCatalog.skill(id: id) } diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift index 5f1d55c..314569c 100644 --- a/OSGKeyboardShared/Services/AIClipboardSkill.swift +++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift @@ -146,6 +146,17 @@ public enum AIClipboardSkillCatalog: Sendable { empathyReplyID, businessReplyID ] + /// Contextual system actions remain available to semantic ranking but are + /// not user-managed entries in the host app's Skills catalog. + public static let hiddenFromSkillManagementIDs: Set = [ + replyID, + declineInvitationID, + empathyReplyID, + acceptInvitationID, + callPhoneID, + createContactID, + clarifyRequestID + ] public static let extractTodosID = "extractTodos" public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosResourceName = "OSGExtractTodos" diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 594b985..e7c95d2 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -86,7 +86,6 @@ public struct AppGroupStore: @unchecked Sendable { public var translationEnabled: Bool { configuration.translationEnabled } public var translationTargetLocaleId: String { configuration.translationTargetLocaleId } public var handednessPreference: HandednessPreference { configuration.handednessPreference } - public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled } public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity } public var polishIntensity: PolishIntensity { configuration.polishIntensity } public var aiResponseLength: AIResponseLength { configuration.aiResponseLength } @@ -140,6 +139,7 @@ public struct AppGroupStore: @unchecked Sendable { public func setLocaleId(_ id: String) { mutateConfiguration { $0.localeId = id } + AppGroupConfigDarwin.postConfigChanged() } public func setEngineMode(_ mode: String) { @@ -180,11 +180,6 @@ public struct AppGroupStore: @unchecked Sendable { AppGroupConfigDarwin.postConfigChanged() } - public func setCursorDragNavigationEnabled(_ enabled: Bool) { - mutateConfiguration { $0.cursorDragNavigationEnabled = enabled } - AppGroupConfigDarwin.postConfigChanged() - } - public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) { mutateConfiguration { $0.keyboardHapticIntensity = intensity } AppGroupConfigDarwin.postConfigChanged() @@ -438,6 +433,19 @@ public struct AppGroupStore: @unchecked Sendable { } // v6 persists canonical IDs for the consolidated reply, summary, // and clarification skills. `sanitized` performs the mapping. + if storedMigrationVersion < 7 { + // Product baseline: install these text skills once for existing + // layouts. Skill-management visibility is a separate policy. + additionIDs.formUnion([ + AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID + ]) + } let additions = catalog.map(\.id).filter { additionIDs.contains($0) && !decoded.enabledIDs.contains($0) } @@ -463,7 +471,7 @@ public struct AppGroupStore: @unchecked Sendable { } } - private static let currentAgentSkillDefaultsMigrationVersion = 6 + private static let currentAgentSkillDefaultsMigrationVersion = 7 private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog { guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else { @@ -547,6 +555,28 @@ public struct AppGroupStore: @unchecked Sendable { #endif } + /// Applies a read-modify-write operation to the latest persisted dictionary. + /// + /// Returning `nil` cancels the write. Callers must not mutate the dictionary + /// before returning `nil`. + @discardableResult + public func updatePersonalDictionary( + _ transform: (inout PersonalDictionary) -> Result? + ) -> (dictionary: PersonalDictionary, result: Result)? { + var output: (dictionary: PersonalDictionary, result: Result)? + mutateConfiguration { config in + guard let result = transform(&config.personalDictionary) else { return } + output = (config.personalDictionary, result) + } + guard let output else { return nil } + + AppGroupConfigDarwin.postConfigChanged() + #if os(iOS) + PersonalDictionaryRimeSync.scheduleAfterDictionaryChange() + #endif + return output + } + public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) { mutateConfiguration { config in config.personalDictionary.entries.removeAll { $0.id == id } diff --git a/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift b/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift index e0fd3e0..41b4229 100644 --- a/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift +++ b/OSGKeyboardShared/Services/ClipboardSkillSemanticRanker.swift @@ -31,8 +31,8 @@ public enum ClipboardSkillSemanticRanker { ) } - /// Returns only skills supported by current semantic evidence. No matching - /// label produces no recommendation instead of a fixed fallback row. + /// Returns semantically relevant skills and always keeps the generic Reply + /// action available as a safe fallback for accepted clipboard text. public static func recommended( skills: [AIClipboardSkill], sourceText: String, @@ -42,22 +42,42 @@ public enum ClipboardSkillSemanticRanker { preferredLanguages: [String] = Locale.preferredLanguages ) -> [AIClipboardSkill] { guard limit > 0 else { return [] } - let scores = relevanceScores( + var scores = relevanceScores( sourceText: sourceText, analysis: analysis, preferredLanguages: preferredLanguages ) + let genericReply = skills.first { $0.id == AIClipboardSkillCatalog.replyID } + if genericReply != nil { + scores[AIClipboardSkillCatalog.replyID, default: 0] = max( + 1, + scores[AIClipboardSkillCatalog.replyID, default: 0] + ) + } let relevant = skills.filter { scores[$0.id, default: 0] > 0 } var selected: [AIClipboardSkill] = [] - var replyCount = 0 + var specializedReplyCount = 0 for skill in sorted(relevant, scores: scores) { - guard selected.count < limit else { break } + let mustReserveGenericReply = genericReply != nil + && !selected.contains(where: { $0.id == AIClipboardSkillCatalog.replyID }) + && skill.id != AIClipboardSkillCatalog.replyID + let availableCount = limit - (mustReserveGenericReply ? 1 : 0) + guard selected.count < availableCount else { continue } + if skill.id == AIClipboardSkillCatalog.replyID { + selected.append(skill) + continue + } if skill.supportsReplyStyle { - guard replyCount < maximumReplyRecommendations else { continue } - replyCount += 1 + guard specializedReplyCount < maximumReplyRecommendations else { continue } + specializedReplyCount += 1 } selected.append(skill) } + if let genericReply, + selected.count < limit, + !selected.contains(where: { $0.id == genericReply.id }) { + selected.append(genericReply) + } return selected } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index ba2b18e..e2187e0 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -142,6 +142,12 @@ public final class KeyboardState: ObservableObject { @Published public var returnKeyRole: ReturnKeyRole = .newline /// Opt-in clipboard history capture (mirrored from App Group). @Published public var clipboardHistoryEnabled: Bool = false + /// Current extension permission state. Clipboard history remains readable + /// without Full Access, but new system pasteboard entries cannot be captured. + @Published public var hasFullAccess: Bool = false + public var needsClipboardFullAccessHint: Bool { + clipboardHistoryEnabled && !hasFullAccess + } /// Opt-in clipboard suggestion strip (requires history enabled). @Published public var clipboardCandidateBarEnabled: Bool = false /// Skills-tab order for clipboard chips. Empty → hint carousel. diff --git a/OSGKeyboardShared/Services/PersonalDictionaryEntryService.swift b/OSGKeyboardShared/Services/PersonalDictionaryEntryService.swift new file mode 100644 index 0000000..8105c9c --- /dev/null +++ b/OSGKeyboardShared/Services/PersonalDictionaryEntryService.swift @@ -0,0 +1,120 @@ +// PersonalDictionaryEntryService.swift +// OSGKeyboard · Shared +// +// Keeps every user-confirmed term on the same save, alias-generation, +// and cloud-sync path, regardless of which UI initiated the change. + +import Foundation + +@MainActor +public final class PersonalDictionaryEntryService { + public typealias AliasGeneration = @MainActor (String) async -> [String] + public typealias CloudPush = @MainActor (PersonalDictionary) async -> Void + + public struct SavedEntry: Sendable { + public let entryID: UUID + public let term: String + public let source: PersonalDictionary.Entry.Source + public let dictionary: PersonalDictionary + public let shouldGenerateAliases: Bool + } + + private let store: AppGroupStore + private let generateAliases: AliasGeneration + private let pushToCloud: CloudPush + + public init( + store: AppGroupStore = AppGroupStore(), + aliasGeneration: AliasGeneration? = nil, + cloudPush: CloudPush? = nil + ) { + self.store = store + if let aliasGeneration { + generateAliases = aliasGeneration + } else { + let generator = DictionaryAliasGenerator() + generateAliases = { term in + await generator.generateAliases(for: term) + } + } + pushToCloud = cloudPush ?? { dictionary in + try? await PersonalDictionaryCloudSync.shared.pushLocalIfEnabled(dictionary) + } + } + + /// Saves immediately so alias generation never blocks the user's action. + public func saveEntry( + term: String, + existingID: UUID? = nil, + source: PersonalDictionary.Entry.Source, + minimumUsageCount: Int? = nil + ) -> SavedEntry? { + var shouldGenerateAliases = false + guard let mutation = store.updatePersonalDictionary({ dictionary -> PersonalDictionary.Entry? in + let previousTerm = existingID.flatMap { id in + dictionary.entries.first(where: { $0.id == id })?.term + } + let termChanged = previousTerm.map { + $0.caseInsensitiveCompare(term) != .orderedSame + } ?? true + + guard let saved = dictionary.upsert( + term: term, + existingID: existingID, + source: source + ), + let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else { + return nil + } + if let minimumUsageCount { + dictionary.entries[index].usageCount = max( + dictionary.entries[index].usageCount, + minimumUsageCount + ) + } + dictionary.version += 1 + shouldGenerateAliases = existingID == nil || termChanged + return dictionary.entries[index] + }) else { + return nil + } + + return SavedEntry( + entryID: mutation.result.id, + term: mutation.result.term, + source: mutation.result.source, + dictionary: mutation.dictionary, + shouldGenerateAliases: shouldGenerateAliases + ) + } + + /// Finishes the asynchronous work against the latest dictionary snapshot. + /// + /// The identity and canonical term checks prevent a late LLM response from + /// restoring a deleted entry or attaching aliases after the term changed. + @discardableResult + public func finishSaving(_ saved: SavedEntry) async -> PersonalDictionary { + await pushToCloud(store.personalDictionary) + guard saved.shouldGenerateAliases else { return store.personalDictionary } + + let aliases = await generateAliases(saved.term) + guard !aliases.isEmpty else { return store.personalDictionary } + + guard let mutation = store.updatePersonalDictionary({ dictionary -> UUID? in + guard let current = dictionary.entries.first(where: { $0.id == saved.entryID }), + current.term == saved.term, + current.source == saved.source else { + return nil + } + dictionary.updateAliases(for: saved.entryID, aliases: aliases) + dictionary.version += 1 + return saved.entryID + }) else { + return store.personalDictionary + } + + await pushToCloud(mutation.dictionary) + // Cloud sync may merge remote entries into local storage while pushing. + return store.personalDictionary + } +} diff --git a/OSGKeyboardShared/Services/PolishStyleLearningService.swift b/OSGKeyboardShared/Services/PolishStyleLearningService.swift index 134731a..649c362 100644 --- a/OSGKeyboardShared/Services/PolishStyleLearningService.swift +++ b/OSGKeyboardShared/Services/PolishStyleLearningService.swift @@ -62,7 +62,7 @@ public struct PolishStyleLearningCorpus: Equatable, Sendable { } public enum PolishStyleLearningCorpusBuilder { - public static let requiredEffectiveCharacterCount = 5_000 + public static let requiredEffectiveCharacterCount = 2_500 public static func build( from entries: [SpeechHistoryEntry] diff --git a/OSGKeyboardShared/Services/SettingsDeepLink.swift b/OSGKeyboardShared/Services/SettingsDeepLink.swift index 202cb28..de03853 100644 --- a/OSGKeyboardShared/Services/SettingsDeepLink.swift +++ b/OSGKeyboardShared/Services/SettingsDeepLink.swift @@ -5,7 +5,10 @@ import Foundation -public enum SettingsDeepLink: String, Sendable { +public enum SettingsDeepLink: String, Sendable, Equatable { + case aiService + case speechRecognition + case textPolish case clipboard private static let pendingKey = "settings.pendingDeepLink" diff --git a/OSGKeyboardShared/Services/UsageStatisticsStore.swift b/OSGKeyboardShared/Services/UsageStatisticsStore.swift index a247cfd..82754cc 100644 --- a/OSGKeyboardShared/Services/UsageStatisticsStore.swift +++ b/OSGKeyboardShared/Services/UsageStatisticsStore.swift @@ -19,11 +19,11 @@ public final class UsageStatisticsStore: ObservableObject { dictationCharacterCount + translationCharacterCount + aiCharacterCount } /// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by - /// the home page's 7-day chart. + /// the home page and dashboard usage visualizations. @Published public private(set) var dailyDictationCharacters: [String: Int] = [:] - /// How many days of daily buckets to retain on disk. Well beyond the 7-day - /// chart window so a device that syncs in late still contributes recent days. + /// How many days of daily buckets to retain on disk. This preserves several + /// complete monthly views when a device syncs late. private static let dailyRetentionDays = 90 public let defaults: UserDefaults @@ -121,13 +121,18 @@ public final class UsageStatisticsStore: ObservableObject { dailyDictationCharacters = payload.aggregatedDailyDictationCharacters } - // MARK: - 7-day chart data + // MARK: - Daily chart data - /// One day's dictation total for the home page chart. + /// One day's dictation total for home and dashboard visualizations. public struct DailyUsagePoint: Identifiable, Equatable, Sendable { public let date: Date public let value: Int public var id: Date { date } + + public init(date: Date, value: Int) { + self.date = date + self.value = value + } } /// The trailing 7 local days (oldest → newest), zero-filled for days with no @@ -151,6 +156,34 @@ public final class UsageStatisticsStore: ObservableObject { return points } + /// Every local-calendar day in the current month, including future days. + /// Missing buckets are zero-filled so the UI can always render a complete + /// calendar instead of changing shape as usage accumulates. + public var currentMonth: [DailyUsagePoint] { + Self.currentMonth(from: dailyDictationCharacters) + } + + public static func currentMonth( + from daily: [String: Int], + now: Date = Date(), + calendar: Calendar = .current + ) -> [DailyUsagePoint] { + let monthComponents = calendar.dateComponents([.year, .month], from: now) + guard let monthStart = calendar.date(from: monthComponents), + let dayRange = calendar.range(of: .day, in: .month, for: monthStart) + else { return [] } + + return dayRange.compactMap { day in + guard let date = calendar.date( + byAdding: .day, + value: day - 1, + to: monthStart + ) else { return nil } + let key = UsageStatisticsDayKey.key(for: date, calendar: calendar) + return DailyUsagePoint(date: date, value: daily[key] ?? 0) + } + } + /// One-time cleanup: the pre-fix code overwrote a device slice with the /// cross-device *sum*, so every reload/record re-added the other devices' /// totals and one slice ballooned to ~8× the true usage. We can't recover diff --git a/OSGKeyboardShared/Typing/EnglishSuggestionEngine.swift b/OSGKeyboardShared/Typing/EnglishSuggestionEngine.swift index eaf5cb0..ac92067 100644 --- a/OSGKeyboardShared/Typing/EnglishSuggestionEngine.swift +++ b/OSGKeyboardShared/Typing/EnglishSuggestionEngine.swift @@ -60,6 +60,9 @@ public struct EnglishSuggestionEngine: Sendable { public static let slotCount = 3 /// In-vocabulary words only yield to a much more common transposition / neighbor. public static let inVocabularyFrequencyGap = 250 + /// Rare words benefit from explicit personal-dictionary treatment; common + /// words are already covered well by the bundled lexicon. + public static let personalTermFrequencyCeiling = 680 private let lexicon: EnglishLexicon @@ -150,6 +153,36 @@ public struct EnglishSuggestionEngine: Sendable { return false } + /// Whether repeated use of this word would add value to PersonalDictionary. + /// Existing personal/system terms and common lexicon words need no prompt. + func isPersonalTermCandidate( + _ word: String, + personalTerms: [String], + systemWords: [String] + ) -> Bool { + guard FrequentTermStore.normalizedCandidate(from: word) != nil, + PersonalDictionary.isEnglishTypingHotword(word) else { + return false + } + + let lower = word.lowercased() + guard !personalTerms.contains(where: { $0.lowercased() == lower }), + !systemWords.contains(where: { $0.lowercased() == lower }) else { + return false + } + + // Preserve intentional product spelling even when the base word is + // common enough to exist in the bundled lexicon (OpenAI, iOS26, GPT-5). + let hasDistinctiveShape = word.dropFirst().contains(where: \.isUppercase) + || word.contains(where: \.isNumber) + || word.contains(where: { "-.+#".contains($0) }) + if hasDistinctiveShape { + return true + } + + return lexicon.frequency(of: lower) <= Self.personalTermFrequencyCeiling + } + // MARK: - Board private struct Board { @@ -304,9 +337,11 @@ public struct EnglishSuggestionEngine: Sendable { private func isProtectedToken(_ typed: String) -> Bool { if typed.count <= 2 { return true } if typed.allSatisfy(\.isUppercase) { return true } + if typed.dropFirst().contains(where: \.isUppercase) { return true } if typed.contains(where: \.isNumber) { return true } if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true } if typed.contains("-") || typed.contains("_") { return true } + if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true } return false } diff --git a/OSGKeyboardShared/Typing/RimeFrequentTermStore.swift b/OSGKeyboardShared/Typing/FrequentTermStore.swift similarity index 64% rename from OSGKeyboardShared/Typing/RimeFrequentTermStore.swift rename to OSGKeyboardShared/Typing/FrequentTermStore.swift index e6cf8cf..7e666da 100644 --- a/OSGKeyboardShared/Typing/RimeFrequentTermStore.swift +++ b/OSGKeyboardShared/Typing/FrequentTermStore.swift @@ -1,13 +1,14 @@ -// RimeFrequentTermStore.swift +// FrequentTermStore.swift // OSGKeyboard · Shared // -// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb -// while the keyboard owns it can race the engine, so the extension records -// eligible committed terms here and the host app ranks them for suggestions. +// Small App Group sidecar for committed typing terms. Reading librime's +// LevelDB userdb while the keyboard owns it can race the engine, so the +// extension records eligible Chinese and English terms here and the host app +// ranks them for personal-dictionary suggestions. import Foundation -public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable { +public struct FrequentTerm: Codable, Equatable, Identifiable, Sendable { public var id: String { term.lowercased() } public let term: String @@ -28,10 +29,11 @@ public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable { } } -/// Captures repeated Rime candidate commits without writing to the curated +/// Captures repeated eligible commits without writing to the curated /// PersonalDictionary until the user explicitly confirms a suggestion. -public final class RimeFrequentTermStore: @unchecked Sendable { - public static let defaultsKey = "rimeTyping.frequentTerms.v1" +public final class FrequentTermStore: @unchecked Sendable { + public static let defaultsKey = "typing.frequentTerms.v2" + public static let legacyRimeDefaultsKey = "rimeTyping.frequentTerms.v1" public static let minimumSuggestionCount = 2 public static let maximumTrackedTerms = 256 @@ -52,7 +54,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable { let key = term.lowercased() if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) { let existing = terms[index] - terms[index] = RimeFrequentTerm( + terms[index] = FrequentTerm( term: term, commitCount: min(10_000, existing.commitCount + 1), firstSeenAt: existing.firstSeenAt, @@ -60,7 +62,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable { ) } else { terms.append( - RimeFrequentTerm( + FrequentTerm( term: term, commitCount: 1, firstSeenAt: date, @@ -78,11 +80,11 @@ public final class RimeFrequentTermStore: @unchecked Sendable { saveLocked(Array(terms.prefix(Self.maximumTrackedTerms))) } - /// Repeated, recent Rime commits that are not already curated. + /// Repeated, recent commits that are not already curated. public func suggestions( excludingPersonalTerms personalTerms: Set, limit: Int = 5 - ) -> [RimeFrequentTerm] { + ) -> [FrequentTerm] { guard limit > 0 else { return [] } let excluded = Set(personalTerms.map { $0.lowercased() }) @@ -111,13 +113,14 @@ public final class RimeFrequentTermStore: @unchecked Sendable { public func clear() { lock.lock() defaults.removeObject(forKey: Self.defaultsKey) + defaults.removeObject(forKey: Self.legacyRimeDefaultsKey) lock.unlock() } static func normalizedCandidate(from text: String) -> String? { let term = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard (2...12).contains(term.count), - !commonTerms.contains(term), + guard (2...32).contains(term.count), + !commonTerms.contains(term.lowercased()), !term.unicodeScalars.contains(where: { CharacterSet.whitespacesAndNewlines.contains($0) }) else { @@ -131,7 +134,8 @@ public final class RimeFrequentTermStore: @unchecked Sendable { semanticCharacterCount += 1 continue } - // Product names and proper nouns commonly contain these separators. + // Product names, proper nouns, and English contractions commonly + // contain these separators. guard allowedSeparators.contains(Character(String(scalar))) else { return nil } @@ -139,21 +143,40 @@ public final class RimeFrequentTermStore: @unchecked Sendable { return semanticCharacterCount >= 2 ? term : nil } - private func loadLocked() -> [RimeFrequentTerm] { - guard let data = defaults.data(forKey: Self.defaultsKey), - let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else { + private func loadLocked() -> [FrequentTerm] { + if let data = defaults.data(forKey: Self.defaultsKey), + let terms = try? JSONDecoder().decode([FrequentTerm].self, from: data) { + return terms + } + + guard let legacyData = defaults.data(forKey: Self.legacyRimeDefaultsKey), + let legacyTerms = try? JSONDecoder().decode([FrequentTerm].self, from: legacyData) else { return [] } - return terms + + // The legacy value has the same Codable shape, so migration only + // changes its key and keeps every user's existing Chinese history. + saveLocked(legacyTerms) + defaults.removeObject(forKey: Self.legacyRimeDefaultsKey) + return legacyTerms } - private func saveLocked(_ terms: [RimeFrequentTerm]) { + private func saveLocked(_ terms: [FrequentTerm]) { guard let data = try? JSONEncoder().encode(terms) else { return } defaults.set(data, forKey: Self.defaultsKey) } /// Avoid recommending ubiquitous conversational glue as a personal term. private static let commonTerms: Set = [ + "a", "about", "after", "all", "also", "am", "an", "and", "any", "are", + "as", "at", "be", "because", "been", "but", "by", "can", "could", "did", + "do", "does", "for", "from", "get", "got", "had", "has", "have", "he", + "her", "here", "him", "his", "how", "i", "if", "in", "is", "it", "its", + "just", "me", "more", "my", "no", "not", "now", "of", "on", "one", "or", + "our", "out", "she", "so", "some", "than", "that", "the", "their", "them", + "then", "there", "they", "this", "to", "too", "up", "us", "was", "we", + "were", "what", "when", "where", "which", "who", "why", "will", "with", + "would", "you", "your", "一个", "一下", "不会", "不是", "什么", "他们", "但是", "你们", "你好", "可能", "可以", "因为", "好的", "如果", "已经", "应该", "怎么", "我们", "所以", "时候", "明天", "昨天", "有点", "没有", "然后", "现在", "知道", @@ -161,5 +184,5 @@ public final class RimeFrequentTermStore: @unchecked Sendable { "那里", "那样", "需要", "今天", "就是" ] - private static let allowedSeparators: Set = ["-", ".", "+", "#", "·"] + private static let allowedSeparators: Set = ["-", "'", ".", "+", "#", "·"] } diff --git a/OSGKeyboardShared/Typing/TypingHabitStore.swift b/OSGKeyboardShared/Typing/TypingHabitStore.swift index 062a8ba..964300b 100644 --- a/OSGKeyboardShared/Typing/TypingHabitStore.swift +++ b/OSGKeyboardShared/Typing/TypingHabitStore.swift @@ -7,14 +7,14 @@ import Foundation public enum TypingHabitStore { - /// Clears English boosts and Chinese Rime user dictionaries. + /// Clears English boosts, cross-language frequent terms, and Rime user dictionaries. /// Does not touch PersonalDictionary / osg_personal. public static func clearAll( englishStore: EnglishLearningStore = EnglishLearningStore(), - rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore() + frequentTermStore: FrequentTermStore = FrequentTermStore() ) async throws { englishStore.clear() - rimeFrequentTermStore.clear() + frequentTermStore.clear() try await RimeResourceInstaller.shared.clearUserDictionary() } } diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift index 56ad06c..7db46f7 100644 --- a/OSGKeyboardShared/Typing/TypingSessionController.swift +++ b/OSGKeyboardShared/Typing/TypingSessionController.swift @@ -15,8 +15,16 @@ public final class TypingSessionController: ObservableObject { @Published public private(set) var capsLock: Bool = false /// Finger is down on Shift (iOS: hold for continuous uppercase; release ends). @Published public private(set) var shiftHeld: Bool = false - @Published public private(set) var composition: TypingComposition = .empty + @Published public private(set) var candidateRevision: UInt64 = 0 + @Published public private(set) var composition: TypingComposition = .empty { + didSet { + if oldValue != composition { + candidateRevision &+= 1 + } + } + } @Published public private(set) var engineReady: Bool = false + @Published public private(set) var isPreparingEngine: Bool = false @Published public private(set) var schema: TypingInputSchema /// Chinese-only: key grid replaced by a same-height candidate grid. @Published public private(set) var isCandidatePanelExpanded: Bool = false @@ -29,8 +37,10 @@ public final class TypingSessionController: ObservableObject { /// Chinese composition is also skipped so passwords never enter Rime userdb. @Published public var suggestionsEnabled: Bool = true { didSet { - guard oldValue, !suggestionsEnabled else { return } - abandonChineseComposition() + guard oldValue != suggestionsEnabled else { return } + if !suggestionsEnabled { + clearDocumentScopedState() + } } } /// `UITextChecker` completions / guesses. Empty in unit tests. @@ -54,12 +64,16 @@ public final class TypingSessionController: ObservableObject { public let layout: TypingLayoutProviding private let engineFactory: @MainActor () -> RimeEngineBridging private let englishFactory: @MainActor () -> EnglishSuggestionEngine + private let hostHeavyProvider: @MainActor () -> Bool private let learningStore: EnglishLearningStore - private let rimeFrequentTermStore: RimeFrequentTermStore + private let frequentTermStore: FrequentTermStore private var engineStorage: RimeEngineBridging? private var englishStorage: EnglishSuggestionEngine? private var prepared = false private var prepareTask: Task? + private var engineEpoch: UInt64 = 0 + private var documentPresentationCounter: UInt64 = 0 + private var activeDocumentPresentationID: UInt64? private var engine: RimeEngineBridging { if let engineStorage { return engineStorage } @@ -99,13 +113,17 @@ public final class TypingSessionController: ObservableObject { layout: TypingLayoutProviding = StandardTypingLayout(), englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil, learningStore: EnglishLearningStore = EnglishLearningStore(), - rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore() + frequentTermStore: FrequentTermStore = FrequentTermStore(), + hostHeavyProvider: @escaping @MainActor () -> Bool = { + FlowSessionBridge.isHostHeavy() + } ) { self.engineFactory = engine ?? { LibrimeEngine() } self.layout = layout self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() } + self.hostHeavyProvider = hostHeavyProvider self.learningStore = learningStore - self.rimeFrequentTermStore = rimeFrequentTermStore + self.frequentTermStore = frequentTermStore // Avoid constructing librime until the first Chinese keystroke / prepare. schema = TypingInputConfiguration.shared.schema } @@ -134,7 +152,7 @@ public final class TypingSessionController: ObservableObject { } public func enterTypingMode() { - let hostHeavy = FlowSessionBridge.isHostHeavy() + let hostHeavy = hostHeavyProvider() KeyboardExtensionMemoryTelemetry.record( "typing.enter.begin", details: "language=\(language.rawValue) schema=\(schema.rawValue) " @@ -165,6 +183,7 @@ public final class TypingSessionController: ObservableObject { OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot") } syncAutocapitalization() + isPreparingEngine = !prepared if hostHeavy { KeyboardExtensionMemoryTelemetry.record( "typing.rimePrepare.deferred", @@ -172,24 +191,28 @@ public final class TypingSessionController: ObservableObject { ) OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot") prepareTask?.cancel() + engineEpoch &+= 1 + let epoch = engineEpoch prepareTask = Task { [weak self] in - for _ in 0..<40 { + while self?.hostHeavyProvider() == true { try? await Task.sleep(nanoseconds: 250_000_000) - guard let self, !Task.isCancelled else { return } - if !FlowSessionBridge.isHostHeavy() { - await self.prepareIfNeeded() - self.prepareTask = nil - return - } + guard !Task.isCancelled else { return } + } + guard let self, self.engineEpoch == epoch, !Task.isCancelled else { return } + await self.prepareIfNeeded(engineEpoch: epoch) + if self.engineEpoch == epoch { + self.prepareTask = nil } - self?.prepareTask = nil } return } if prepareTask == nil, !prepared { + engineEpoch &+= 1 + let epoch = engineEpoch prepareTask = Task { [weak self] in - await self?.prepareIfNeeded() - self?.prepareTask = nil + await self?.prepareIfNeeded(engineEpoch: epoch) + guard let self, self.engineEpoch == epoch else { return } + self.prepareTask = nil } } } @@ -200,17 +223,14 @@ public final class TypingSessionController: ObservableObject { details: "language=\(language.rawValue)" ) OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot") + engineEpoch &+= 1 prepareTask?.cancel() prepareTask = nil + endDocumentPresentation() engineStorage?.teardown() prepared = false engineReady = false - composition = .empty - isCandidatePanelExpanded = false - page = .letters - resetShiftState() - clearEnglishWordState(keepPrevious: false) - clearPeriodShortcut() + isPreparingEngine = false // Drop English lexicon pages when leaving typing (jetsam recovery). EnglishLexicon.shared.unload() englishStorage = nil @@ -220,6 +240,32 @@ public final class TypingSessionController: ObservableObject { ) } + /// Starts a new host-document scope without tearing down dictionaries or + /// the Rime runtime. A keyboard presentation can move between apps while + /// the extension process and controller remain alive. + @discardableResult + public func beginDocumentPresentation() -> UInt64 { + documentPresentationCounter &+= 1 + let presentationID = documentPresentationCounter + activeDocumentPresentationID = presentationID + candidateRevision &+= 1 + clearDocumentScopedState() + return presentationID + } + + /// Invalidates all candidates and input shadows owned by the outgoing + /// document. Heavy engines stay warm until typing mode itself is left. + public func endDocumentPresentation() { + documentPresentationCounter &+= 1 + activeDocumentPresentationID = nil + candidateRevision &+= 1 + clearDocumentScopedState() + } + + public func isCurrentDocumentPresentation(_ presentationID: UInt64) -> Bool { + activeDocumentPresentationID == presentationID + } + public func toggleCandidatePanelExpanded() { guard canExpandCandidatePanel else { isCandidatePanelExpanded = false @@ -248,7 +294,7 @@ public final class TypingSessionController: ObservableObject { if !raw.isEmpty { output = .insert(raw) } } language = newLanguage - engine.setLanguage(newLanguage) + engineStorage?.setLanguage(newLanguage) page = .letters isCandidatePanelExpanded = false clearPeriodShortcut() @@ -258,7 +304,9 @@ public final class TypingSessionController: ObservableObject { englishEngine.prepare() refreshEnglishSuggestions() syncAutocapitalization() - synchronizeEnglishDocumentContext(caretMoved: true) + if activeDocumentPresentationID != nil { + synchronizeEnglishDocumentContext(caretMoved: true) + } } else { clearEnglishWordState(keepPrevious: false) EnglishLexicon.shared.unload() @@ -267,6 +315,14 @@ public final class TypingSessionController: ObservableObject { return output } + /// Reloads host-curated terms after App Group configuration changes. + public func reloadPersonalDictionaryTerms() { + refreshPersonalTerms() + if language == .english { + refreshEnglishSuggestions() + } + } + /// Flushes raw preedit, selects the next built-in scheme, and returns the /// text that the caller should insert before switching. public func cycleSchema() -> TypingOutput { @@ -371,7 +427,7 @@ public final class TypingSessionController: ObservableObject { composition = engine.composition syncCandidatePanelVisibility() clearOneShotShiftIfNeeded() - recordRimeCommit(committed) + recordChineseCommit(committed) return committed.isEmpty ? .none : .insert(committed) } @@ -387,7 +443,7 @@ public final class TypingSessionController: ObservableObject { let text = engine.processSpace() ?? " " composition = engine.composition syncCandidatePanelVisibility() - recordRimeCommit(text) + recordChineseCommit(text) return .insert(text) } @@ -403,7 +459,7 @@ public final class TypingSessionController: ObservableObject { let text = engine.processReturn() ?? "\n" composition = engine.composition syncCandidatePanelVisibility() - recordRimeCommit(text) + recordChineseCommit(text) return .insert(text) } @@ -423,13 +479,28 @@ public final class TypingSessionController: ObservableObject { // Selecting always collapses; follow-up composition may reopen ▼. isCandidatePanelExpanded = false syncCandidatePanelVisibility() - recordRimeCommit(text) + recordChineseCommit(text) return text.isEmpty ? .none : .insert(text) } - private func recordRimeCommit(_ text: String) { + /// Candidate taps originate from a rendered snapshot. Reject the tap when + /// that snapshot has already been replaced by a newer composition. + public func selectCandidate( + id candidateID: String, + candidateRevision expectedRevision: UInt64? = nil + ) -> TypingOutput { + if let expectedRevision, expectedRevision != candidateRevision { + return .none + } + guard let index = composition.candidates.firstIndex(where: { $0.id == candidateID }) else { + return .none + } + return selectCandidate(at: index) + } + + private func recordChineseCommit(_ text: String) { guard language == .chinese, suggestionsEnabled, !text.isEmpty else { return } - rimeFrequentTermStore.recordCommittedText(text) + frequentTermStore.recordCommittedText(text) } /// Drop in-flight pinyin so secure fields cannot commit into userdb. @@ -575,6 +646,7 @@ public final class TypingSessionController: ObservableObject { UIKitEnglishSystemLexicon.learnWord(word) #endif } + recordEnglishCommit(word) refreshEnglishSuggestions(afterCommittedWord: word) return suffix.isEmpty ? .none : .insert(suffix) } @@ -597,6 +669,7 @@ public final class TypingSessionController: ObservableObject { #if canImport(UIKit) UIKitEnglishSystemLexicon.learnWord(pending.original) #endif + recordEnglishCommit(pending.original) refreshEnglishSuggestions(afterCommittedWord: pending.original) return .replace(deleteCount: deleteCount, with: pending.original + " ") } @@ -609,6 +682,7 @@ public final class TypingSessionController: ObservableObject { } else { learningStore.recordAcceptance(of: chosen) } + recordEnglishCommit(chosen) if !englishCurrentWord.isEmpty { let deleteCount = englishCurrentWord.count @@ -627,6 +701,19 @@ public final class TypingSessionController: ObservableObject { return .insert(chosen + " ") } + private func recordEnglishCommit(_ text: String) { + guard language == .english, + suggestionsEnabled, + englishEngine.isPersonalTermCandidate( + text, + personalTerms: personalTermsCache, + systemWords: supplementaryWords + ) else { + return + } + frequentTermStore.recordCommittedText(text) + } + private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput { guard language == .english, !englishCurrentWord.isEmpty else { return .none } return commitEnglishWord(suffix: "") @@ -696,8 +783,15 @@ public final class TypingSessionController: ObservableObject { /// Rebuilds English suggestion state from the real caret context. /// At the document end, callbacks keep the local shadow when a host /// briefly reports the immediately preceding edit (common in Notes). - public func synchronizeEnglishDocumentContext(caretMoved: Bool = false) { + public func synchronizeEnglishDocumentContext( + caretMoved: Bool = false, + presentationID: UInt64? = nil + ) { guard language == .english else { return } + if let presentationID, + activeDocumentPresentationID != presentationID { + return + } if caretMoved { clearPeriodShortcut() } @@ -843,6 +937,16 @@ public final class TypingSessionController: ObservableObject { pendingAutocorrection = nil } + private func clearDocumentScopedState() { + engineStorage?.clearComposition() + composition = .empty + isCandidatePanelExpanded = false + page = .letters + resetShiftState() + clearEnglishWordState(keepPrevious: false) + clearPeriodShortcut() + } + private func synchronizeEnglishWordState( precedingText: String, followingText: String? @@ -942,15 +1046,19 @@ public final class TypingSessionController: ObservableObject { } } - private func prepareIfNeeded() async { + private func prepareIfNeeded(engineEpoch expectedEpoch: UInt64) async { guard !prepared else { return } - if FlowSessionBridge.isHostHeavy() { + guard engineEpoch == expectedEpoch, !Task.isCancelled else { return } + if hostHeavyProvider() { KeyboardExtensionMemoryTelemetry.record( "typing.rimePrepare.deferred", details: "hostHeavy=1" ) OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot") - return + while hostHeavyProvider() { + try? await Task.sleep(nanoseconds: 250_000_000) + guard engineEpoch == expectedEpoch, !Task.isCancelled else { return } + } } KeyboardExtensionMemoryTelemetry.record( "typing.rimePrepare.begin", @@ -963,9 +1071,11 @@ public final class TypingSessionController: ObservableObject { ) do { try await engine.prepare() + guard engineEpoch == expectedEpoch, !Task.isCancelled else { return } engine.setLanguage(language) prepared = true engineReady = engine.isReady + isPreparingEngine = false schema = engine.schema lastError = nil lastErrorNeedsHostDeployment = false @@ -981,10 +1091,12 @@ public final class TypingSessionController: ObservableObject { category: "boot" ) } catch { + guard engineEpoch == expectedEpoch, !Task.isCancelled else { return } lastError = error.localizedDescription lastErrorNeedsHostDeployment = (error as? RimeResourceError)?.isResolvedByHostDeployment ?? false engineReady = false + isPreparingEngine = false KeyboardExtensionMemoryTelemetry.record( "typing.rimePrepare.failed", details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))" @@ -1005,9 +1117,13 @@ public final class TypingSessionController: ObservableObject { guard prepareTask == nil else { return } guard RimeResourceInstaller.isReady else { return } OSGDiag.log("rime.prepare retry after deployment", category: "boot") + engineEpoch &+= 1 + let epoch = engineEpoch + isPreparingEngine = true prepareTask = Task { [weak self] in - await self?.prepareIfNeeded() - self?.prepareTask = nil + await self?.prepareIfNeeded(engineEpoch: epoch) + guard let self, self.engineEpoch == epoch else { return } + self.prepareTask = nil } } } diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index d51aa06..960c2ce 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -153,7 +153,7 @@ /* v0.3.0: Personal dictionary sources */ "dict.source.manual" = "Manual"; -"dict.source.history" = "Auto-learned"; +"dict.source.history" = "Recommended hotword"; "dict.source.contacts" = "From Contacts"; "dict.source.recentEdit" = "From recent edit"; @@ -202,7 +202,7 @@ "mac.styles.learn.generating" = "Generating…"; "mac.styles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving."; "mac.styles.learn.limit" = "Delete a custom style before generating another one."; -"mac.styles.learn.error.insufficient" = "Keep dictating until 5,000 effective characters are available."; +"mac.styles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available."; "mac.styles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again."; "mac.styles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again."; "mac.styles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again."; @@ -234,6 +234,7 @@ "stat.weekChart.title" = "Last 7 days"; "stat.weekChart.caption" = "Dictation chars"; "stat.weekChart.empty" = "No dictation yet this week"; +"stat.monthChart.caption" = "Monthly dictation chars"; "mac.status.chipReady" = "Ready"; "mac.status.chipProcessing" = "Processing"; "mac.overlay.listening" = "Listening"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index dfec985..a9986ec 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -152,7 +152,7 @@ /* v0.3.0: 词库来源 */ "dict.source.manual" = "手动添加"; -"dict.source.history" = "自动学习"; +"dict.source.history" = "推荐热词"; "dict.source.contacts" = "来自通讯录"; "dict.source.recentEdit" = "来自最近编辑"; @@ -201,7 +201,7 @@ "mac.styles.learn.generating" = "生成中…"; "mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; -"mac.styles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。"; +"mac.styles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。"; "mac.styles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。"; "mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "mac.styles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。"; @@ -233,6 +233,7 @@ "stat.weekChart.title" = "近 7 天"; "stat.weekChart.caption" = "听写字数"; "stat.weekChart.empty" = "本周还没有听写记录"; +"stat.monthChart.caption" = "本月听写字数"; "mac.status.chipReady" = "就绪"; "mac.status.chipProcessing" = "处理中"; "mac.overlay.listening" = "聆听中"; diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift index d792740..9e8a1bf 100644 --- a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift +++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift @@ -21,6 +21,37 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty) } + func testSystemSemanticSkillsStayEnabledButHiddenFromSkillManagement() { + let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) + let hiddenIDs = AIClipboardSkillCatalog.hiddenFromSkillManagementIDs + + XCTAssertTrue(hiddenIDs.isSubset(of: Set(store.enabledSkills.map(\.id)))) + XCTAssertTrue( + hiddenIDs.isDisjoint(with: Set(store.skillManagementEnabledSkills.map(\.id))) + ) + XCTAssertTrue( + hiddenIDs.isDisjoint(with: Set(store.skillManagementAvailableSkills.map(\.id))) + ) + XCTAssertTrue( + store.skillManagementEnabledSkills.contains { + $0.id == AIClipboardSkillCatalog.translateID + } + ) + + store.disable(AIClipboardSkillCatalog.replyID) + + XCTAssertFalse( + store.skillManagementAvailableSkills.contains { + $0.id == AIClipboardSkillCatalog.replyID + } + ) + XCTAssertTrue( + store.mergedCatalog.contains { + $0.id == AIClipboardSkillCatalog.replyID + } + ) + } + func testEmptyEnabledListIsPreserved() { let defaults = makeDefaults() let store = AppGroupStore(defaults: defaults) @@ -30,7 +61,7 @@ final class AIAgentSkillLayoutTests: XCTestCase { XCTAssertEqual(store.agentSkillLayout.enabledIDs, []) } - func testLegacyLayoutAppendsNewDefaultSkillsWithoutRestoringDisabledLegacySkill() throws { + func testLegacyLayoutInstallsCurrentRequiredDefaultSkills() throws { let defaults = makeDefaults() let legacy = AIAgentSkillLayout( enabledIDs: [ @@ -50,7 +81,7 @@ final class AIAgentSkillLayoutTests: XCTestCase { Array(migrated.enabledIDs.prefix(2)), [AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID] ) - XCTAssertFalse(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID)) + XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.summarizeID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.acceptInvitationID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID)) } @@ -77,10 +108,16 @@ final class AIAgentSkillLayoutTests: XCTestCase { [ AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.playfulReplyID, + AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.openLinkID, AIClipboardSkillCatalog.summarizeWebPageID, AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID ] ) } @@ -106,10 +143,16 @@ final class AIAgentSkillLayoutTests: XCTestCase { migrated.enabledIDs, [ AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.openLinkID, AIClipboardSkillCatalog.summarizeWebPageID, AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID ] ) } @@ -135,8 +178,14 @@ final class AIAgentSkillLayoutTests: XCTestCase { migrated.enabledIDs, [ AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID ] ) } @@ -167,17 +216,63 @@ final class AIAgentSkillLayoutTests: XCTestCase { [ AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.summarizeID, - AIClipboardSkillCatalog.clarifyRequestID + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.translateID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID ] ) XCTAssertEqual( defaults.integer( forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion ), - 6 + 7 ) } + func testVersionSixLayoutInstallsRequiredDefaultsOnlyOnce() throws { + let defaults = makeDefaults() + let initial = AIAgentSkillLayout( + enabledIDs: [AIClipboardSkillCatalog.playfulReplyID], + confirmedShortcutIDs: [] + ) + defaults.set( + try JSONEncoder().encode(initial), + forKey: AppGroupConfiguration.Keys.agentSkillLayout + ) + defaults.set( + 6, + forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion + ) + + let store = AppGroupStore(defaults: defaults) + XCTAssertEqual( + store.agentSkillLayout.enabledIDs, + [ + AIClipboardSkillCatalog.playfulReplyID, + AIClipboardSkillCatalog.replyID, + AIClipboardSkillCatalog.translateID, + AIClipboardSkillCatalog.summarizeID, + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.clarifyRequestID, + AIClipboardSkillCatalog.empathyReplyID, + AIClipboardSkillCatalog.organizeListID + ] + ) + + store.setAgentSkillLayout( + AIAgentSkillLayout( + enabledIDs: store.agentSkillLayout.enabledIDs.filter { + $0 != AIClipboardSkillCatalog.replyID + }, + confirmedShortcutIDs: [] + ) + ) + + XCTAssertFalse(store.agentSkillLayout.isEnabled(AIClipboardSkillCatalog.replyID)) + } + func testCannotEnableExportSkillBeforeShortcutConfirmation() { let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) store.disable(AIClipboardSkillCatalog.extractTodosID) diff --git a/OSGKeyboardTests/AIHistoryAndUsageTests.swift b/OSGKeyboardTests/AIHistoryAndUsageTests.swift index 5617ddc..a3efd9d 100644 --- a/OSGKeyboardTests/AIHistoryAndUsageTests.swift +++ b/OSGKeyboardTests/AIHistoryAndUsageTests.swift @@ -34,6 +34,33 @@ final class AIHistoryAndUsageTests: XCTestCase { XCTAssertEqual(store.totalInputCharacterCount, 4) } + @MainActor + func testCurrentMonthBuildsCompleteLeapMonthAndZeroFillsMissingDays() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try XCTUnwrap(TimeZone(secondsFromGMT: 0)) + let now = try XCTUnwrap( + calendar.date(from: DateComponents(year: 2024, month: 2, day: 14)) + ) + + let points = UsageStatisticsStore.currentMonth( + from: [ + "2024-02-01": 120, + "2024-02-29": 480 + ], + now: now, + calendar: calendar + ) + + XCTAssertEqual(points.count, 29) + XCTAssertEqual(points.first?.value, 120) + XCTAssertEqual(points[13].value, 0) + XCTAssertEqual(points.last?.value, 480) + XCTAssertEqual( + points.map { calendar.component(.day, from: $0.date) }, + Array(1...29) + ) + } + func testLegacyHistoryEntryDefaultsToDictationSource() throws { let payload: [String: Any] = [ "id": UUID().uuidString, diff --git a/OSGKeyboardTests/AccountAPIClientTests.swift b/OSGKeyboardTests/AccountAPIClientTests.swift index e4c5ea1..9d3ecfa 100644 --- a/OSGKeyboardTests/AccountAPIClientTests.swift +++ b/OSGKeyboardTests/AccountAPIClientTests.swift @@ -194,6 +194,51 @@ final class AccountAPIClientTests: XCTestCase { XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh") } + func testRecreatedClientRestoresRetainedSessionAndRefreshesIt() async throws { + let retained = makeAccountSession(accessExpiry: 1_020) + let replacement = makeAccountSession( + accessToken: "access-after-reinstall", + refreshToken: "refresh-after-reinstall" + ) + let transport = QueueAccountTransport([ + .init(statusCode: 200, body: try sessionEnvelopeData(retained)), + .init(statusCode: 200, body: try sessionEnvelopeData(replacement)) + ]) + let store = InMemoryAccountSecurityStore() + let originalClient = AccountAPIClient( + baseURL: URL(string: "https://account.test")!, + transport: transport, + sessionVault: store, + now: { Date(timeIntervalSince1970: 1_000) } + ) + _ = try await originalClient.signInWithApple( + AppleSignInRequest( + identityToken: "identity", + authorizationCode: "authorization", + nonce: "raw-nonce", + deviceCheckToken: "device-token", + appAttest: nil + ) + ) + + let recreatedClient = AccountAPIClient( + baseURL: URL(string: "https://account.test")!, + transport: transport, + sessionVault: store, + now: { Date(timeIntervalSince1970: 1_000) } + ) + let accessToken = try await recreatedClient.accessTokenForAuthorizedRequest() + + XCTAssertEqual(accessToken, replacement.accessToken) + let stored = await store.session + XCTAssertEqual(stored, replacement) + let requests = await transport.requests + XCTAssertEqual(requests.map(\.url?.path), [ + "/v1/auth/apple", + "/v1/auth/refresh" + ]) + } + func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws { let old = makeAccountSession() let replacement = makeAccountSession( diff --git a/OSGKeyboardTests/AccountCenterViewModelTests.swift b/OSGKeyboardTests/AccountCenterViewModelTests.swift index bb479bf..1987ad8 100644 --- a/OSGKeyboardTests/AccountCenterViewModelTests.swift +++ b/OSGKeyboardTests/AccountCenterViewModelTests.swift @@ -60,6 +60,7 @@ final class AccountCenterViewModelTests: XCTestCase { ) await coordinator.restoreIfNeeded() + XCTAssertEqual(coordinator.sessionPhase, .signedOut) let handled = coordinator.handleIncomingURL( URL(string: "https://osglab.com/i/\(validCode)")! ) @@ -123,6 +124,83 @@ final class AccountCenterViewModelTests: XCTestCase { XCTAssertEqual(restoreCount, 1) } + @MainActor + func testTransientRestoreFailureCanRetryWithoutSigningOut() async { + let account = AccountSession( + accountID: UUID(), + createdAtEpochSeconds: 1_700_000_000 + ) + let service = AccountServiceSpy( + restoredSession: account, + snapshot: makeSnapshot(account: account), + restoreFailureCount: 1 + ) + let coordinator = AccountSessionCoordinator( + dependencies: AccountDependencies( + sessionService: service, + centerService: service + ), + pendingReferralStore: InMemoryPendingReferralStore() + ) + + await coordinator.restoreIfNeeded() + + XCTAssertEqual(coordinator.sessionPhase, .restoring) + XCTAssertEqual(coordinator.operationErrorKey, "account.error.restore") + let firstClearCount = await service.managedGatewayClearCount() + XCTAssertEqual(firstClearCount, 0) + + await coordinator.restoreIfNeeded() + + XCTAssertEqual(coordinator.sessionPhase, .signedIn(account)) + XCTAssertNil(coordinator.operationErrorKey) + let restoreCount = await service.restoreCount() + XCTAssertEqual(restoreCount, 2) + } + + @MainActor + func testSignInOperationRemainsVisibleUntilAuthenticationCompletes() async { + let account = AccountSession( + accountID: UUID(), + createdAtEpochSeconds: 1_700_000_000 + ) + let service = AccountServiceSpy( + restoredSession: nil, + signInSession: account, + snapshot: makeSnapshot(account: account), + signInDelayNanoseconds: 20_000_000 + ) + let coordinator = AccountSessionCoordinator( + dependencies: AccountDependencies( + sessionService: service, + centerService: service + ), + pendingReferralStore: InMemoryPendingReferralStore() + ) + await coordinator.restoreIfNeeded() + + let signIn = Task { + await coordinator.signIn( + with: AppleAuthorizationPayload( + identityToken: "identity", + authorizationCode: "authorization", + nonce: "nonce" + ) + ) + } + while coordinator.operation != .signingIn { + await Task.yield() + } + + XCTAssertEqual(coordinator.operation, .signingIn) + XCTAssertEqual(coordinator.sessionPhase, .signedOut) + + await signIn.value + + XCTAssertNil(coordinator.operation) + XCTAssertEqual(coordinator.sessionPhase, .signedIn(account)) + } + @MainActor func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() async { let account = AccountSession( @@ -529,12 +607,15 @@ private final class AccountSessionEventSourceStub: AccountSessionEventSourcing { private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing { private let restored: AccountSession? + private let signedInAccount: AccountSession? private let centerSnapshot: AccountCenterSnapshot? private let refreshedSnapshot: AccountCenterSnapshot? private let shouldFailRedemption: Bool private let shouldFailAccountRefresh: Bool private let signOutDelayNanoseconds: UInt64 + private let signInDelayNanoseconds: UInt64 private let accountLoadDelayNanoseconds: UInt64 + private var remainingRestoreFailures: Int private var redeemed: [String] = [] private var centerLoadCount = 0 private var logoutCount = 0 @@ -545,30 +626,43 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing init( restoredSession: AccountSession?, + signInSession: AccountSession? = nil, snapshot: AccountCenterSnapshot? = nil, refreshedSnapshot: AccountCenterSnapshot? = nil, shouldFailRedemption: Bool = false, shouldFailAccountRefresh: Bool = false, signOutDelayNanoseconds: UInt64 = 0, + signInDelayNanoseconds: UInt64 = 0, + restoreFailureCount: Int = 0, accountLoadDelayNanoseconds: UInt64 = 0 ) { restored = restoredSession + signedInAccount = signInSession ?? restoredSession centerSnapshot = snapshot self.refreshedSnapshot = refreshedSnapshot self.shouldFailRedemption = shouldFailRedemption self.shouldFailAccountRefresh = shouldFailAccountRefresh self.signOutDelayNanoseconds = signOutDelayNanoseconds + self.signInDelayNanoseconds = signInDelayNanoseconds + remainingRestoreFailures = restoreFailureCount self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds } func restoreSession() async throws -> AccountSession? { sessionRestoreCount += 1 + if remainingRestoreFailures > 0 { + remainingRestoreFailures -= 1 + throw AccountServiceSpyError.failed + } return restored } func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession { - guard let restored else { throw AccountIntegrationError.unavailable } - return restored + if signInDelayNanoseconds > 0 { + try await Task.sleep(nanoseconds: signInDelayNanoseconds) + } + guard let signedInAccount else { throw AccountIntegrationError.unavailable } + return signedInAccount } func signOut() async throws { diff --git a/OSGKeyboardTests/AppGroupConfigurationTests.swift b/OSGKeyboardTests/AppGroupConfigurationTests.swift index c3fc2cb..bd6bc50 100644 --- a/OSGKeyboardTests/AppGroupConfigurationTests.swift +++ b/OSGKeyboardTests/AppGroupConfigurationTests.swift @@ -30,7 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId) XCTAssertFalse(config.translationEnabled) XCTAssertEqual(config.handednessPreference, .left) - XCTAssertTrue(config.cursorDragNavigationEnabled) + // Legacy field remains decode-compatible after the UI feature was removed. + XCTAssertFalse(config.cursorDragNavigationEnabled) XCTAssertEqual(config.keyboardHapticIntensity, .light) XCTAssertEqual(config.polishIntensity, .light) XCTAssertEqual(config.aiResponseLength, .medium) @@ -93,6 +94,24 @@ final class AppGroupConfigurationTests: XCTestCase { XCTAssertEqual(loaded.flowInactivityDuration, .threeHours) } + func testAppGroupStorePersistsLocaleChanges() { + let defaults = makeDefaults() + let store = AppGroupStore(defaults: defaults) + + store.setLocaleId("en-US") + + XCTAssertEqual(store.localeId, "en-US") + } + + func testRemovedCursorDragSettingStillDecodesLegacyValue() { + let defaults = makeDefaults() + defaults.set(true, forKey: AppGroupConfiguration.Keys.cursorDragNavigationEnabled) + + let loaded = AppGroupConfiguration.load(fromAvailable: defaults) + + XCTAssertTrue(loaded.cursorDragNavigationEnabled) + } + func testFieldLevelSavePreservesNewerUnrelatedProcessChange() { let defaults = makeDefaults() let baseline = AppGroupConfiguration.load(fromAvailable: defaults) diff --git a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift index a2c96a6..e8a9fd6 100644 --- a/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift +++ b/OSGKeyboardTests/ClipboardSkillSemanticRankerTests.swift @@ -45,7 +45,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { preferredLanguages: ["zh-Hans"] ).map(\.id) - XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.translateID]) + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.translateID, + AIClipboardSkillCatalog.replyID + ] + ) } func testInvitationWithDatePromotesCalendarAndBothReplyChoices() { @@ -91,7 +97,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { recommendations, [ AIClipboardSkillCatalog.openLinkID, - AIClipboardSkillCatalog.summarizeWebPageID + AIClipboardSkillCatalog.summarizeWebPageID, + AIClipboardSkillCatalog.replyID ] ) } @@ -111,7 +118,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { recommendations, [ AIClipboardSkillCatalog.openLinkID, - AIClipboardSkillCatalog.summarizeWebPageID + AIClipboardSkillCatalog.summarizeWebPageID, + AIClipboardSkillCatalog.replyID ] ) } @@ -130,6 +138,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.openLinkID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeWebPageID)) + XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) } func testSinglePhoneNumberOffersCallAndCreateContact() { @@ -149,7 +158,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { recommendations, [ AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.replyID ] ) } @@ -169,7 +179,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { recommendations, [ AIClipboardSkillCatalog.callPhoneID, - AIClipboardSkillCatalog.createContactID + AIClipboardSkillCatalog.createContactID, + AIClipboardSkillCatalog.replyID ] ) } @@ -190,6 +201,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.callPhoneID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.createContactID)) + XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) } func testTaskListPromotesTodoAndOrganizationSkills() { @@ -259,7 +271,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { XCTAssertEqual(ranked, baseline) } - func testRecommendationsSelectOnlySemanticallyRelevantSkills() { + func testRecommendationsAddReplyToSemanticallyRelevantSkills() { let recommendations = ClipboardSkillSemanticRanker.recommended( skills: AIClipboardSkillCatalog.catalog, sourceText: "北京市朝阳区望京街 10 号,到了给我电话。", @@ -268,12 +280,18 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { limit: 5 ).map(\.id) - XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.navigateID]) + XCTAssertEqual( + recommendations, + [ + AIClipboardSkillCatalog.navigateID, + AIClipboardSkillCatalog.replyID + ] + ) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID)) } - func testRecommendationsStayEmptyWhenNoSemanticLabelMatches() { + func testRecommendationsFallBackToReplyWhenNoSemanticLabelMatches() { let recommendations = ClipboardSkillSemanticRanker.recommended( skills: AIClipboardSkillCatalog.catalog, sourceText: "知道了", @@ -282,7 +300,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { limit: 5 ).map(\.id) - XCTAssertTrue(recommendations.isEmpty) + XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) } func testNegativeReplyableMessageDoesNotOfferPlayfulReply() { @@ -300,7 +318,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) } - func testInvitationKeepsTwoSpecificRepliesWithoutGenericReplies() { + func testInvitationKeepsSpecificRepliesAndGenericReply() { let recommendations = ClipboardSkillSemanticRanker.recommended( skills: AIClipboardSkillCatalog.catalog, sourceText: "今晚七点老地方吃饭,你能来吗?", @@ -319,12 +337,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase { [ AIClipboardSkillCatalog.extractEventsID, AIClipboardSkillCatalog.acceptInvitationID, - AIClipboardSkillCatalog.declineInvitationID + AIClipboardSkillCatalog.declineInvitationID, + AIClipboardSkillCatalog.replyID ] ) } - func testRecommendationsNeverContainMoreThanTwoReplySkills() { + func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() { let recommendations = ClipboardSkillSemanticRanker.recommended( skills: AIClipboardSkillCatalog.catalog, sourceText: "Could you send the final proposal by Friday?", diff --git a/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift b/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift index 221b524..7940a11 100644 --- a/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift +++ b/OSGKeyboardTests/FlowHomePiPStatusPolicyTests.swift @@ -116,6 +116,47 @@ final class FlowHomePiPStatusPolicyTests: XCTestCase { ) } + func testLocalAndPolishOnlySetupLinksToTextPolish() { + XCTAssertEqual( + HomeServiceSetupPolicy.apiKeyDeepLink( + isLocalEngine: true, + isASRConfigured: true + ), + .textPolish + ) + XCTAssertEqual( + HomeServiceSetupPolicy.apiKeyDeepLink( + isLocalEngine: false, + isASRConfigured: true + ), + .textPolish + ) + XCTAssertEqual( + HomeServiceSetupPolicy.apiKeyMessageKey( + isLocalEngine: false, + isASRConfigured: true + ), + "home.setup.polishKeyMissing" + ) + } + + func testCloudSetupLinksToSpeechRecognitionWhenASRIsMissing() { + XCTAssertEqual( + HomeServiceSetupPolicy.apiKeyDeepLink( + isLocalEngine: false, + isASRConfigured: false + ), + .speechRecognition + ) + XCTAssertEqual( + HomeServiceSetupPolicy.apiKeyMessageKey( + isLocalEngine: false, + isASRConfigured: false + ), + "home.setup.cloudIncomplete" + ) + } + private func descriptor( for lifecycle: FlowPiPLifecycleState ) -> FlowHomePiPStatusDescriptor { diff --git a/OSGKeyboardTests/RimeFrequentTermStoreTests.swift b/OSGKeyboardTests/FrequentTermStoreTests.swift similarity index 53% rename from OSGKeyboardTests/RimeFrequentTermStoreTests.swift rename to OSGKeyboardTests/FrequentTermStoreTests.swift index 0f54ab7..67ea388 100644 --- a/OSGKeyboardTests/RimeFrequentTermStoreTests.swift +++ b/OSGKeyboardTests/FrequentTermStoreTests.swift @@ -1,15 +1,15 @@ -// RimeFrequentTermStoreTests.swift +// FrequentTermStoreTests.swift // OSGKeyboardTests @testable import OSGKeyboardShared import XCTest -final class RimeFrequentTermStoreTests: XCTestCase { +final class FrequentTermStoreTests: XCTestCase { private var defaults: UserDefaults! private var suiteName: String! override func setUpWithError() throws { - suiteName = "RimeFrequentTermStoreTests.\(UUID().uuidString)" + suiteName = "FrequentTermStoreTests.\(UUID().uuidString)" defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) } @@ -19,8 +19,8 @@ final class RimeFrequentTermStoreTests: XCTestCase { suiteName = nil } - func testRepeatedRimeCommitBecomesSuggestion() { - let store = RimeFrequentTermStore(defaults: defaults) + func testRepeatedCommitBecomesSuggestion() { + let store = FrequentTermStore(defaults: defaults) store.recordCommittedText("少数派") XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty) @@ -32,7 +32,7 @@ final class RimeFrequentTermStoreTests: XCTestCase { } func testSuggestionsExcludeExistingDictionaryTermsAndCommonWords() { - let store = RimeFrequentTermStore(defaults: defaults) + let store = FrequentTermStore(defaults: defaults) for _ in 0..<4 { store.recordCommittedText("我们") store.recordCommittedText("飞书文档") @@ -47,7 +47,7 @@ final class RimeFrequentTermStoreTests: XCTestCase { } func testSuggestionsRankFrequencyBeforeRecency() { - let store = RimeFrequentTermStore(defaults: defaults) + let store = FrequentTermStore(defaults: defaults) let earlier = Date(timeIntervalSince1970: 100) let later = Date(timeIntervalSince1970: 200) for _ in 0..<3 { @@ -64,7 +64,7 @@ final class RimeFrequentTermStoreTests: XCTestCase { } func testPunctuationAndSingleCharactersAreIgnored() { - let store = RimeFrequentTermStore(defaults: defaults) + let store = FrequentTermStore(defaults: defaults) for _ in 0..<3 { store.recordCommittedText("我") store.recordCommittedText("你好!") @@ -75,7 +75,7 @@ final class RimeFrequentTermStoreTests: XCTestCase { } func testClearRemovesLearnedSuggestions() { - let store = RimeFrequentTermStore(defaults: defaults) + let store = FrequentTermStore(defaults: defaults) store.recordCommittedText("少数派") store.recordCommittedText("少数派") XCTAssertFalse(store.suggestions(excludingPersonalTerms: []).isEmpty) @@ -84,4 +84,48 @@ final class RimeFrequentTermStoreTests: XCTestCase { XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty) } + + func testEnglishTermsAreCaseInsensitiveAndCommonWordsAreIgnored() { + let store = FrequentTermStore(defaults: defaults) + for term in ["OpenAI", "openAI", "the", "the"] { + store.recordCommittedText(term) + } + + let suggestion = store.suggestions(excludingPersonalTerms: []).first + + XCTAssertEqual(suggestion?.term, "openAI") + XCTAssertEqual(suggestion?.commitCount, 2) + } + + func testEnglishNamesAndProductSeparatorsAreAccepted() { + let store = FrequentTermStore(defaults: defaults) + for term in ["O'Connor", "O'Connor", "GPT-5", "GPT-5"] { + store.recordCommittedText(term) + } + + XCTAssertEqual( + Set(store.suggestions(excludingPersonalTerms: []).map(\.term)), + ["O'Connor", "GPT-5"] + ) + } + + func testLegacyRimeTermsMigrateWithoutLosingHistory() throws { + let legacyTerm = FrequentTerm( + term: "少数派", + commitCount: 3, + firstSeenAt: Date(timeIntervalSince1970: 100), + lastSeenAt: Date(timeIntervalSince1970: 200) + ) + defaults.set( + try JSONEncoder().encode([legacyTerm]), + forKey: FrequentTermStore.legacyRimeDefaultsKey + ) + + let store = FrequentTermStore(defaults: defaults) + let suggestions = store.suggestions(excludingPersonalTerms: []) + + XCTAssertEqual(suggestions.first, legacyTerm) + XCTAssertNotNil(defaults.data(forKey: FrequentTermStore.defaultsKey)) + XCTAssertNil(defaults.data(forKey: FrequentTermStore.legacyRimeDefaultsKey)) + } } diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift index d43a8b6..15b4318 100644 --- a/OSGKeyboardTests/IntelligentPolishTests.swift +++ b/OSGKeyboardTests/IntelligentPolishTests.swift @@ -147,15 +147,15 @@ final class IntelligentPolishTests: XCTestCase { XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym) } - func testPersonalDictionaryMigratesLegacyHistorySource() { - let legacy = PersonalDictionary(entries: [ + func testPersonalDictionaryPreservesRecommendedHistorySource() { + let dictionary = PersonalDictionary(entries: [ PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history) ]) - let data = try! JSONEncoder().encode(legacy) + let data = try! JSONEncoder().encode(dictionary) defaults.set(data, forKey: "config.personalDictionary.v1") let loaded = store.personalDictionary - XCTAssertEqual(loaded.entries.first?.source, .manual) + XCTAssertEqual(loaded.entries.first?.source, .history) XCTAssertEqual(loaded.entries.first?.term, "Kubernetes") } diff --git a/OSGKeyboardTests/PersonalDictionaryEntryServiceTests.swift b/OSGKeyboardTests/PersonalDictionaryEntryServiceTests.swift new file mode 100644 index 0000000..fdb2944 --- /dev/null +++ b/OSGKeyboardTests/PersonalDictionaryEntryServiceTests.swift @@ -0,0 +1,128 @@ +// PersonalDictionaryEntryServiceTests.swift +// OSGKeyboardTests +// +// Verifies that every manual-entry UI gets the same alias-generation behavior +// without allowing a late asynchronous response to overwrite newer edits. + +@testable import OSGKeyboardShared +import XCTest + +@MainActor +final class PersonalDictionaryEntryServiceTests: XCTestCase { + private var suiteName: String! + private var defaults: UserDefaults! + private var store: AppGroupStore! + + override func setUp() { + super.setUp() + suiteName = "group.com.osgkeyboard.shared.tests.entry-service.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + store = AppGroupStore(defaults: defaults) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + super.tearDown() + } + + func testSuggestedTermGeneratesAliasesAndPreservesUsageCount() async throws { + var pushedDictionaries: [PersonalDictionary] = [] + let service = PersonalDictionaryEntryService( + store: store, + aliasGeneration: { _ in ["洛基", "肉鸡"] }, + cloudPush: { pushedDictionaries.append($0) } + ) + + let saved = try XCTUnwrap( + service.saveEntry(term: "Rocky", source: .history, minimumUsageCount: 3) + ) + XCTAssertTrue(saved.shouldGenerateAliases) + XCTAssertEqual(saved.dictionary.entries.first?.source, .history) + XCTAssertEqual(saved.dictionary.entries.first?.usageCount, 3) + XCTAssertTrue(saved.dictionary.entries.first?.aliases.isEmpty == true) + + let finished = await service.finishSaving(saved) + + XCTAssertEqual(Set(finished.entries.first?.aliases ?? []), Set(["洛基", "肉鸡"])) + XCTAssertEqual(finished.entries.first?.usageCount, 3) + XCTAssertEqual(pushedDictionaries.count, 2) + } + + func testAliasCompletionPreservesChangesMadeWhileGenerating() async throws { + let service = PersonalDictionaryEntryService( + store: store, + aliasGeneration: { _ in ["swift u i"] }, + cloudPush: { _ in } + ) + let saved = try XCTUnwrap(service.saveEntry(term: "SwiftUI", source: .manual)) + store.updatePersonalDictionary { dictionary -> UUID? in + guard let concurrent = dictionary.upsertManual(term: "Cursor") else { return nil } + dictionary.version += 1 + return concurrent.id + } + + let finished = await service.finishSaving(saved) + + XCTAssertEqual(Set(finished.entries.map(\.term)), Set(["SwiftUI", "Cursor"])) + XCTAssertEqual( + finished.entry(matchingTerm: "SwiftUI")?.aliases, + ["swift u i"] + ) + } + + func testAliasCompletionDoesNotRestoreDeletedEntry() async throws { + let service = PersonalDictionaryEntryService( + store: store, + aliasGeneration: { _ in ["late alias"] }, + cloudPush: { _ in } + ) + let saved = try XCTUnwrap(service.saveEntry(term: "Deleted", source: .manual)) + store.deletePersonalDictionaryEntry(id: saved.entryID) + + let finished = await service.finishSaving(saved) + + XCTAssertNil(finished.entries.first(where: { $0.id == saved.entryID })) + XCTAssertNotNil(finished.deletedEntryIDs[saved.entryID]) + } + + func testAliasCompletionIgnoresEntryRenamedAfterSave() async throws { + let service = PersonalDictionaryEntryService( + store: store, + aliasGeneration: { _ in ["stale alias"] }, + cloudPush: { _ in } + ) + let saved = try XCTUnwrap(service.saveEntry(term: "Before", source: .manual)) + store.updatePersonalDictionary { dictionary -> UUID? in + guard let renamed = dictionary.upsertManual( + term: "After", + existingID: saved.entryID + ) else { + return nil + } + dictionary.version += 1 + return renamed.id + } + + let finished = await service.finishSaving(saved) + + XCTAssertEqual(finished.entries.first?.term, "After") + XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true) + } + + func testAliasGenerationFailureKeepsSavedEntry() async throws { + var pushCount = 0 + let service = PersonalDictionaryEntryService( + store: store, + aliasGeneration: { _ in [] }, + cloudPush: { _ in pushCount += 1 } + ) + let saved = try XCTUnwrap(service.saveEntry(term: "Durable", source: .manual)) + + let finished = await service.finishSaving(saved) + + XCTAssertEqual(finished.entries.first?.term, "Durable") + XCTAssertTrue(finished.entries.first?.aliases.isEmpty == true) + XCTAssertEqual(pushCount, 1) + } +} diff --git a/OSGKeyboardTests/PolishStyleLearningServiceTests.swift b/OSGKeyboardTests/PolishStyleLearningServiceTests.swift index f72f8fd..d5b9c79 100644 --- a/OSGKeyboardTests/PolishStyleLearningServiceTests.swift +++ b/OSGKeyboardTests/PolishStyleLearningServiceTests.swift @@ -1,7 +1,7 @@ // PolishStyleLearningServiceTests.swift // OSGKeyboard · Tests // -// Verifies corpus eligibility, the 5,000-character gate, and that style +// Verifies corpus eligibility, the 2,500-character gate, and that style // generation receives both paired examples and the prompts that produced them. @testable import OSGKeyboardShared @@ -54,7 +54,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { XCTAssertEqual(corpus.examples.count, 1) XCTAssertEqual(corpus.examples.first?.polishStyleID, "builtin.light") XCTAssertEqual(corpus.effectiveCharacterCount, 7) - XCTAssertEqual(corpus.remainingCharacterCount, 4_993) + XCTAssertEqual(corpus.remainingCharacterCount, 2_493) XCTAssertFalse(corpus.isReady) } @@ -74,8 +74,8 @@ final class PolishStyleLearningServiceTests: XCTestCase { XCTAssertEqual(corpus.effectiveCharacterCount, 7) } - func testCorpusUnlocksAtFiveThousandEffectiveCharacters() { - let text = String(repeating: "字", count: 5_000) + func testCorpusUnlocksAtTwoThousandFiveHundredEffectiveCharacters() { + let text = String(repeating: "字", count: 2_500) let corpus = PolishStyleLearningCorpusBuilder.build( from: [ SpeechHistoryEntry( @@ -86,7 +86,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { ] ) - XCTAssertEqual(corpus.effectiveCharacterCount, 5_000) + XCTAssertEqual(corpus.effectiveCharacterCount, 2_500) XCTAssertEqual(corpus.remainingCharacterCount, 0) XCTAssertTrue(corpus.isReady) } @@ -108,7 +108,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { store.setPolishStyleCatalog(catalog) store.setActivePolishStyleId(activeStyle.id) - let source = String(repeating: "测试语料", count: 1_250) + let source = String(repeating: "测试语料", count: 625) let corpus = PolishStyleLearningCorpus( examples: [ PolishStyleLearningExample( @@ -120,7 +120,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { createdAt: Date() ) ], - effectiveCharacterCount: 5_000 + effectiveCharacterCount: 2_500 ) let client = StyleLearningCapturingClient( response: ##"{"name":"我的说话风格","prompt":"# 角色\n自然直接\n# 风格边界\n不改变原意\n# 示例\n输入 → 输出","allowsAddedEmoji":false}"## @@ -156,7 +156,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { createdAt: Date() ) ], - effectiveCharacterCount: 5_000 + effectiveCharacterCount: 2_500 ) let service = PolishStyleLearningService( store: store, @@ -169,7 +169,7 @@ final class PolishStyleLearningServiceTests: XCTestCase { } catch let error as PolishStyleLearningError { XCTAssertEqual( error, - .insufficientCorpus(required: 5_000, actual: 5) + .insufficientCorpus(required: 2_500, actual: 5) ) } catch { XCTFail("Unexpected error: \(error)") diff --git a/OSGKeyboardUITests/AccountCenterUITests.swift b/OSGKeyboardUITests/AccountCenterUITests.swift index 850963f..f991554 100644 --- a/OSGKeyboardUITests/AccountCenterUITests.swift +++ b/OSGKeyboardUITests/AccountCenterUITests.swift @@ -35,6 +35,19 @@ final class AccountCenterUITests: XCTestCase { XCTAssertTrue(element("account.purchase.3000tks", in: app).exists) } + func testAppleSignInShowsLoadingStateUntilAuthenticationCompletes() { + let app = launch(arguments: [ + "--account-ui-test", + "--account-signin-loading" + ]) + + XCTAssertTrue( + element("account.signIn.loading", in: app) + .waitForExistence(timeout: 3) + ) + XCTAssertFalse(element("account.signIn.apple", in: app).exists) + } + func testVerifiedCreditPurchaseShowsSuccessState() { let app = launch() let purchase = element("account.purchase.500tks", in: app) @@ -59,6 +72,12 @@ final class AccountCenterUITests: XCTestCase { element("account.purchaseHistory.list", in: app) .waitForExistence(timeout: 5) ) + let purchaseRow = element( + "account.purchaseHistory.row.2000000000199", + in: app + ) + XCTAssertTrue(purchaseRow.waitForExistence(timeout: 5)) + XCTAssertGreaterThanOrEqual(purchaseRow.frame.height, 64) } func testManagedCreditsRequireFirstUseCloudConsent() { diff --git a/README.en.md b/README.en.md index 43f9ae1..44f7b69 100644 --- a/README.en.md +++ b/README.en.md @@ -7,7 +7,7 @@ OSGKeyboard is a voice input tool for iPhone, iPad, and Mac. Its iOS keyboard co ![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) ![Platform](https://img.shields.io/badge/macOS-15%2B-555?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) -![Version](https://img.shields.io/badge/version-2.0.1-3aa05a) +![Version](https://img.shields.io/badge/version-2.0.3-3aa05a) ![License](https://img.shields.io/badge/license-Source%20Available-blue) [Website](https://hkgood.github.io/OSGKeyboard/?lang=en) · [中文](./README.md) · [Privacy Policy](https://hkgood.github.io/OSGKeyboard/privacy/) · [Changelog](./CHANGELOG.md) @@ -52,10 +52,10 @@ OSGKeyboard is a voice input tool for iPhone, iPad, and Mac. Its iOS keyboard co ### Clipboard and skills - Clipboard history is off by default; when enabled, it keeps up to 15 plain-text items in the device-local App Group -- Reply, Summarize, and Translate appear briefly after a copy +- On-device semantic analysis recommends up to five relevant actions from the complete skill catalog after a copy - Built-in skills extract tasks and events, save to Notes, and open map navigation through Apple Shortcuts or map apps - Create custom skills with a name, SF Symbol, prompt, and optional iCloud Shortcut -- Pin up to eight skills to the keyboard and reorder them by long press +- Install any number of skills; manage and reorder them in the Skills tab while the keyboard dynamically shows relevant actions for the current clipboard text ## Three service paths diff --git a/README.md b/README.md index f851210..cfc8427 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的语音输入工具。iOS 键盘 ![Platform](https://img.shields.io/badge/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple) ![Platform](https://img.shields.io/badge/macOS-15%2B-555?logo=apple) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) -![Version](https://img.shields.io/badge/version-2.0.1-3aa05a) +![Version](https://img.shields.io/badge/version-2.0.3-3aa05a) ![License](https://img.shields.io/badge/license-Source%20Available-blue) [官网](https://hkgood.github.io/OSGKeyboard/) · [English](./README.en.md) · [隐私政策](https://hkgood.github.io/OSGKeyboard/privacy/) · [更新记录](./CHANGELOG.md) @@ -52,10 +52,10 @@ OSGKeyboard 是面向 iPhone、iPad 与 Mac 的语音输入工具。iOS 键盘 ### 剪贴板与技能 - 剪贴板历史默认关闭,开启后最多保存 15 条纯文本,仅存于本机 App Group -- 复制后短时间显示回复、总结、翻译等快捷技能 +- 复制后由本地语义分析从完整技能目录中推荐最多 5 个相关操作 - 内置待办、日程与备忘录可通过 Apple 快捷指令导出;地图导航直接打开高德、百度或 Apple 地图 - 可创建自定义技能:名称、SF Symbol、提示词和可选 iCloud 快捷指令 -- 最多将 8 个技能放到键盘,支持长按拖动排序 +- 技能安装数量不限;技能页可安装、卸载和排序,键盘按当前剪贴板内容动态展示相关技能 ## 三种服务路径 diff --git a/Scripts/resolve_test_suite.py b/Scripts/resolve_test_suite.py index cac0181..1a2f4ac 100755 --- a/Scripts/resolve_test_suite.py +++ b/Scripts/resolve_test_suite.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse import json +import re import sys from pathlib import Path @@ -25,6 +26,12 @@ TEST_ROOTS = [ ] SHARED_TEST_ROOT = ROOT / "Tests" SHARED_TEST_TARGETS = ["OSGKeyboardTests", "OSGKeyboardMacTests"] +XCTEST_CLASS_PATTERN = re.compile( + r"^[ \t]*(?:(?:@\w+(?:\([^)]*\))?)[ \t]+)*" + r"(?:final[ \t]+)?class[ \t]+([A-Za-z_]\w*)" + r"[ \t]*:[ \t]*XCTestCase\b", + re.MULTILINE, +) def load_manifest() -> dict: @@ -77,6 +84,11 @@ def collect_tests(manifest: dict, group_ids: list[str]) -> list[str]: return tests +def discover_xctest_classes(path: Path) -> list[str]: + """Return every XCTestCase class declared in a Swift test source file.""" + return XCTEST_CLASS_PATTERN.findall(path.read_text(encoding="utf-8")) + + def discover_on_disk_test_classes() -> dict[str, Path]: """Map Target/ClassName -> swift path for *Tests.swift files (exclude helpers).""" found: dict[str, Path] = {} @@ -86,14 +98,15 @@ def discover_on_disk_test_classes() -> dict[str, Path]: target = root.name for path in sorted(root.glob("*Tests.swift")): # Skip non-XCTest helpers that happen to end with Tests (none today). - class_name = path.stem - test_id = f"{target}/{class_name}" - found[test_id] = path + for class_name in discover_xctest_classes(path): + test_id = f"{target}/{class_name}" + found[test_id] = path if SHARED_TEST_ROOT.is_dir(): for path in sorted(SHARED_TEST_ROOT.glob("*Tests.swift")): - for target in SHARED_TEST_TARGETS: - test_id = f"{target}/{path.stem}" - found[test_id] = path + for class_name in discover_xctest_classes(path): + for target in SHARED_TEST_TARGETS: + test_id = f"{target}/{class_name}" + found[test_id] = path return found diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index ed61d8b..5992ac0 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -63,6 +63,7 @@ "tests": [ "OSGKeyboardTests/SettingsCloudSyncTests", "OSGKeyboardTests/PersonalDictionaryCloudSyncTests", + "OSGKeyboardTests/PersonalDictionaryEntryServiceTests", "OSGKeyboardTests/PersonalDictionaryMergeTests", "OSGKeyboardTests/SpeechHistoryCloudSyncTests", "OSGKeyboardTests/SpeechHistoryDayDeletionTests", @@ -178,12 +179,14 @@ "OSGKeyboardTests/KeyboardTranslationConfigProtectionTests", "OSGKeyboardTests/AIHintPoolTests", "OSGKeyboardTests/AIHintKeywordExtractorTests", + "OSGKeyboardTests/AIClipboardSkillTests", "OSGKeyboardTests/WebPageContentServiceTests", "OSGKeyboardTests/AIAgentSkillLayoutTests", "OSGKeyboardTests/AIUserSkillTests", "OSGKeyboardTests/AIUserSkillStoreTests", "OSGKeyboardTests/AIEventExtractionTests", "OSGKeyboardTests/AIAddressExtractionTests", + "OSGKeyboardTests/AIMapNavigationTests", "OSGKeyboardTests/AINoteExportTests", "OSGKeyboardTests/ClipboardSemanticAnalyzerTests", "OSGKeyboardTests/ClipboardSkillSemanticRankerTests", @@ -191,7 +194,7 @@ "OSGKeyboardTests/ClipboardHistoryStoreTests", "OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests", "OSGKeyboardTests/OfficialSkillCatalogTests", - "OSGKeyboardTests/RimeFrequentTermStoreTests" + "OSGKeyboardTests/FrequentTermStoreTests" ] }, "host_misc": { diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index 63592c2..c0e7797 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -1,4 +1,4 @@ -# App Store Connect — OSGKeyboard 2.0.1 (build 89) +# App Store Connect — OSGKeyboard 2.0.3 (build 94) > Current metadata baseline for the iOS/iPadOS App Store build. Version and build > numbers come from `project.yml`. The repository also contains a separate @@ -11,7 +11,7 @@ | App name | `OSGKeyboard` | ≤ 30 characters | | Subtitle | `Voice input, everywhere` | ≤ 30 characters | | Bundle ID | `com.osgkeyboard.ios` | iOS host target | -| Version / build | `2.0.1` / `88` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` | +| Version / build | `2.0.3` / `94` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` | | Minimum system | iOS/iPadOS 26 | iPhone and iPad | | Primary locale | `en-US` | Simplified Chinese is also bundled | | Primary category | Utilities | | @@ -106,25 +106,22 @@ Voice input anywhere, with on-device recognition by default. Use your own AI key keyboard,voice,dictation,speech,transcribe,AI,pinyin,Chinese,English,polish,typing,productivity ``` -## What's new in 2.0.1 +## What's new in 2.0.3 ```text NEW -• A shorter first-run guide teaches dictation, translation, smart reply, - and voice questions before the optional account step. -• Official AI skills can refresh without an App Store update. -• Private typing summaries and local Rime term suggestions improve insights - without retaining typed text. +• Home can now suggest repeated rare English terms alongside Chinese terms + for explicit review before adding them to the Personal Dictionary. CHANGED -• iPhone navigation now uses the native iOS 26 Liquid Glass tab bar. -• Voice history retains private paired metadata for future style learning. +• Cards, settings typography, native selection menus, account rows, and + primary-to-detail navigation transitions now follow one visual system. FIXED -• Signing out or losing a session now switches every AI path back to BYOK - and blocks cached managed grants from consuming credits. -• Analytics uploads are safer during suspension, purchase confirmations no - longer reappear, and account/settings refreshes are more reliable. +• Apple account restoration now retries transient secure-session failures and + keeps sign-in loading feedback consistent. +• English and Chinese candidates now recover reliably after switching apps + without carrying state from the previous input field. ``` ## App Review information @@ -285,7 +282,7 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. ## Submission checklist -- [ ] Confirm `project.yml` still reads version 2.0.1 / build 89 +- [ ] Confirm `project.yml` still reads version 2.0.3 / build 94 - [ ] Open the existing Xcode project (do not regenerate unless needed) - [ ] Run the release build and test suites on macOS with Xcode 26 - [ ] Replace screenshots with captures from the submitted build @@ -296,4 +293,4 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. and mapped to the server credit catalog - [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks no feature -- [ ] Upload, select build 89, add review notes, and submit +- [ ] Upload, select build 94, add review notes, and submit diff --git a/docs/keyboard-accuracy-plan.md b/docs/keyboard-accuracy-plan.md index fbf3e2f..38247d6 100644 --- a/docs/keyboard-accuracy-plan.md +++ b/docs/keyboard-accuracy-plan.md @@ -194,7 +194,7 @@ **工程注意(规划级)** - 宜收敛为网格/行级触摸跟踪,而非继续堆 per-key `Button` -- 可复用 `RepeatingPressButton` / `CursorDragPad` 的 UIKit 触控经验,但字母键生命周期以 Up 确认为准 +- 可复用 `RepeatingPressButton` 与现有 UIKit 触控跟踪经验,但字母键生命周期以 Up 确认为准 - 需单列与候选栏、Shift、123 切换的手势边界用例 --- diff --git a/docs/llms.txt b/docs/llms.txt index 1113f85..b7ad222 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -2,7 +2,7 @@ > OSGKeyboard is a source-available voice and typing tool for iPhone, iPad, and Mac. The iOS keyboard combines on-device dictation, Chinese and English typing, a unified AI assistant, clipboard skills, and optional managed credits. On Mac, hold Option for global dictation. -Current repository version: 2.0.1 (build 89). Later work is listed under CHANGELOG [Unreleased]. +Current repository version: 2.0.1 (build 90). Later work is listed under CHANGELOG [Unreleased]. ## Product facts diff --git a/docs/mac-parity-audit-2026-08-23.md b/docs/mac-parity-audit-2026-08-23.md new file mode 100644 index 0000000..babc1bc --- /dev/null +++ b/docs/mac-parity-audit-2026-08-23.md @@ -0,0 +1,818 @@ +# OSGKeyboard · iOS 完整检查 + macOS 差距与移植建议报告 + +> **报告日期**:2026-08-23 +> **当前版本**:2.0.1(build 90)— `project.yml:51-52` +> **审查范围**:iOS Host (`OSGKeyboard/`, 88 .swift) + iOS HostSupport (`OSGKeyboardHostSupport/`, 29 .swift) + iOS Extension (`OSGKeyboardExt/`, 27 .swift) + Shared (`OSGKeyboardShared/`, 230 .swift) + macOS (`OSGKeyboardMac/`, 39 .swift) + macOS Tests (3 .swift) +> **方法**:只读代码考古,证据来自 `project.yml` 共享 / 排除清单 + 文件结构 + 关键源码摘录 + `CHANGELOG.md` + +--- + +## TL;DR + +| 维度 | iOS | macOS | 结论 | +|---|---|---|---| +| 产品形态 | 宿主 App + 自定义键盘扩展 | 单窗口菜单栏 App + 浮动 HUD | **不同形态**——Mac 不是键盘,是"按住 Option 说话→⌘V 注入" | +| 核心流水线 | Flow 跨进程会话 + SpeechAnalyzer | 单一进程 + MLX Audio Qwen3 流式 | **架构不可同构** | +| LLM 润色 | ✅ 完整(`PolishingService`) | ✅ 完整(**直接复用** iOS `PolishingService`) | **完全对齐** | +| 本地 ASR | iOS 26 `SpeechAnalyzer + DictationTranscriber` | **MLX Qwen3-ASR 0.6B/1.7B 4-bit** + Apple Speech 兜底 | **完全不同的技术栈** | +| 云 ASR | 7 家供应商 + 流式 | ✅ **完全对齐**(同 `CloudASRClientFactory`) | **完全对齐** | +| 个人词典 | ✅ | ✅ | **完全对齐** | +| 历史 / 使用统计 | ✅ | ✅(iCloud KVS 共用) | **完全对齐** | +| 润色样式 + 学习 | ✅ | ✅ | **完全对齐** | +| iCloud 同步 | ✅(5 路:设置/词典/历史/使用/样式) | ✅(**5 路全部对齐**) | **完全对齐** | +| 提示页 (Tip) | ✅ | ✅ | **完全对齐** | +| 自定义语言模型 CLM | ✅ UI + 后台 | ⚠️ 后台调用了,**无 UI** | **部分缺失** | +| 剪贴板历史 + AI 技能 | ✅ | ❌ 共享代码已编译但**无 Mac UI** | **完全缺失** | +| AI 提示轮播 / 上下文技能 | ✅ | ❌ 共享代码已编译但**无 Mac UI** | **完全缺失** | +| Flow 跨进程会话 / PiP | ✅ | ❌ | **Mac 形态不需要** | +| 自定义键盘 (Rime/拼音/英文) | ✅ | ❌(`Typing/**` 全排除) | **Mac 形态不需要** | +| OSG 账户 (Sign in with Apple + 积分 + 推荐) | ✅ | ❌ | **完全缺失** | +| App Attest | ✅ | ❌(无对应 entitlement) | **完全缺失** | +| 一方分析 (Analytics) | ✅(108 测试覆盖) | ❌(共享代码已编译但**无任何调用**) | **完全缺失** | +| 助手指令 (Shortcuts) | ✅ | ❌ | **完全缺失** | +| 屏幕唤醒 / 锁 / Live Activity | ✅ | ❌ | **Mac 形态不需要** | + +**最关键的发现**: + +1. **macOS 复用策略 = 源码级 include**。Mac 不 `import OSGKeyboardShared`——`project.yml:570-602` 把 Shared + HostSupport 的 `.swift` 文件直接编进 Mac target,再用 `excludes:` 列表过滤 iOS-only 文件。**整个框架里没有 `#if os(macOS)` 条件编译**(Mac target 内部有几处 `import AppKit` 守卫,是防御性的)。 +2. **LLM 润色、云 ASR、iCloud 同步、本地 ASR 模型管理、模型目录、个人词典、润色样式、提示页、Onboarding——这 9 大块是 iOS / Mac 完全对齐的**。差异主要在"宿主 App 才有"的系统集成(Account、App Attest、Analytics、Shortcuts、Flow 跨进程会话)。 +3. **"共享代码已编进 Mac 但零调用"的浪费**:`OSGKeyboardShared/Features/Account/`、`Features/ManagedGateway/`、`Features/Analytics/`、`Services/AIClipboard*.swift`、`Services/AIHint*.swift`、`Services/AIUserSkill*.swift` —— 这些文件编译进了 Mac 二进制但没有任何 Mac 文件引用。**编译器开销 + 二进制体积 + 维护面全白付**。要么真正接进 Mac,要么从 Mac target 排除。 +4. **Mac 真正独占的体验是 MLX 流式 ASR**。这是 iOS 受限于 ARM NEON + iOS 26 平台绑定做不到的能力,恰好把 Mac 的 Apple Silicon 算力用足。 +5. **移植优先级不应是"把 iOS 全搬到 Mac"**。Mac 是"按住说话→润色→注入"工具,自然形态完全不同。**真正应该补的是:剪贴板 AI、CLM 管理、Account 体系、Analytics。**其他(Flow、自定义键盘)不应该硬塞进 Mac。 + +--- + +## 1. iOS 端完整状态 + +### 1.1 Target 拓扑 + +| Target | 平台 | 类型 | .swift 数 | 备注 | +|---|---|---|---|---| +| `OSGKeyboard` | iOS 26 | App | 88 | 宿主 App,沙盒、StoreKit、iCloud、App Attest | +| `OSGKeyboardExt` | iOS 26 | App Extension (键盘服务) | 27 | `RequestsOpenAccess: true` | +| `OSGKeyboardShared` | iOS 26 | Framework | 230 | 跨进程共用 + 复用到 Mac | +| `OSGKeyboardHostSupport` | iOS 26 | Framework | 29 | 宿主专用(Speech/AVFoundation/Charts/StoreKit) | +| `OSGKeyboardMac` | macOS 15+ | App | 39 | 菜单栏单窗口,**沙盒关闭**(Accessibility 需要) | +| `OSGKeyboardMacTests` | macOS 15+ | Unit Test | 3 | 挂在 Mac App bundle 内 | +| `OSGKeyboardTests` | iOS 26 | Unit Test | 108 | 覆盖 host + shared + hostsupport | +| `OSGKeyboardExtTests` | iOS 26 | Unit Test | 16 | 覆盖 ext + shared | +| `OSGKeyboardUITests` | iOS 26 | UI Test | — | TestFlight 截图自动化 | + +### 1.2 iOS Host App (`OSGKeyboard/`) — 用户可见的所有功能 + +**导航**:4 Tab (Keyboard / Skills / Styles / Settings) on iPhone;iPad 自动切换到 `NavigationSplitView` + 240pt 侧栏 + `WideStatusFooter`。 + +**7 大用户场景**: + +1. **Onboarding**(`Views/OnboardingExperienceView.swift`)—— 7 步:欢迎 → 权限 → 键盘安装 → 切换引导 → 4-feature 实践(语音/翻译/回复/AI)→ 登录奖励 → 完成。匿名实践用 `OOBEClientInfrastructure` 拿服务器发放的体验金。 +2. **Home 仪表盘**(`Views/HomeView.swift:122`)—— 7 天柱状图 + 指标瓦片 + Flow 连接卡 + 词典/历史卡 + 中英文高频词条 chip。 +3. **Settings**(`Views/SettingsView.swift:30`)—— 7 个子面板:账户、AI 代理、剪贴板、Locale、润色强度、翻译目标、语音识别、文本润色、通用、关于。NavigationStack + `SettingsRoute` 枚举。 +4. **Account Center**(`Views/Account/AccountCenterView.swift:11`)—— 已登录摘要、积分 + 等级、StoreKit 商品列表、购买历史链接、登出 / 删号 (reauth)、推荐链接。下拉刷新。 +5. **Polish Styles**(`Views/PolishStylesView.swift:11`)—— 内置/社区/自定义样式网格 + 编辑器 sheet + "从历史学习"按钮。 +6. **AI Agent Skills**(`Views/AIAgentSkillsView.swift:12`)—— 已装/未装技能卡,安装调起 `shortcuts://` 或 bundled `.shortcut` 文件。 +7. **Legal / 隐私**—— `PrivacyPolicyView` / `OpenSourceLicensesView` / 远程 web view。 + +**宿主端 Services(`OSGKeyboard/Services/`)** 关键件: + +| 文件 | 职责 | LOC 估算 | +|---|---|---| +| `FlowSessionManager.swift` | **唯一拥有者**——AVAudioSession 激活、连续 `.playAndRecord` 捕获、ASR 选择、后处理、`PolishingService` 调用、AI 模式、App Group 写、utterance token、launch reconciliation、start/stop/end session | ~3500 | +| `FlowPictureInPictureController.swift` | PiP keep-alive(`AVPictureInPictureVideoCallViewController` 0.1pt content,no audio session) | — | +| `FlowASRPostProcessor.swift` | 本地引擎转写后处理(`LocalASRTranscriptCorrector.apply`) | — | +| `FlowTerminationCoordinator.swift` | 进程退出时同步释放 mic、结束 LiveActivity | — | +| `FlowAnalyticsOperationRegistry.swift` | 会话级分析去重,订阅 `FlowSessionManager` 事件 | — | +| `RimeDeploymentController.swift` | Rime 资源部署(host-owned) | — | +| `AIAgentShortcutInstaller.swift` | 调起 `shortcuts://` 安装 iCloud 分享的技能 / bundled `.shortcut` | — | +| `OOBEClientInfrastructure.swift` | 匿名 OOBE 体验金客户端 | — | +| `OfficialSkillCatalogRefreshService.swift` | 15 分钟 ETag-aware 刷新 `https://account.osglab.com/v1/content/skills` | — | +| `AIHintRefreshService.swift` | 12 小时静默刷新远程提示包 | — | +| `AnalyticsHostService.swift` | `BGTaskScheduler` 注册 (`com.osgkeyboard.ios.analytics-sync`) + `NWPathMonitor` + 后台 drain | — | +| `HostReturnService.swift` | 解析 `FlowSessionBridge.pendingHostBundleId()` 通过 `HostAppURLRegistry` 重开原 App | — | +| `AppURLHandler.swift` | `UIApplicationDelegate` + `UISceneDelegate`:`osgkeyboard://startflow` / `https://osglab.com/i/...` 通用链接 / `didBecomeActiveNotification` 反弹保留 `sourceApplication` | — | +| `AppPermissions.swift` | `AVAudioApplication.requestRecordPermission` (iOS 17+) + `SFSpeechRecognizer` + `PasteAccessResult` 一次性 `pasteAccessVerified` | — | + +**宿主端 Models 关键点**: +- `AppGroupConfiguration` 完整 Codable blob —— 镜像到 App Group + iCloud KVS +- `ProviderConfig` 8 家供应商预设(openai/ark/deepseek/qwen/zhipu/moonshot/siliconflow/groq) + +**关键 iOS-only 调用**: +- `UIOpenURLContext.options.sourceApplication` (iOS 26 only) —— 保留源 bundle id(host-return 白名单唯一路径) +- `AVAudioApplication.requestRecordPermission` (iOS 17+) +- `SpeechAnalyzer + DictationTranscriber` (iOS 26 only) +- `OSAllocatedUnfairLock` (iOS 16+) +- iOS 26 Icon Composer (`OSGKeyboard/AppIcon.icon`) +- `UIBackgroundModes: audio` + `BGTaskScheduler` +- `applinks:osglab.com` Universal Links +- `LSApplicationQueriesSchemes` 白名单 50+ 国外 App scheme +- `SKIncludeConsumableInAppPurchaseHistory=true` —— StoreKit 2 一次性商品进历史 + +**iOS-only entitlements**(在 `OSGKeyboard.entitlements`): +- App Group `group.com.osgkeyboard.shared`(与扩展共享) +- 两个 keychain-access-groups(shared + iOS-only) +- `aps-environment: $(APP_ATTEST_ENVIRONMENT)` ← xcconfig 注入(debug=development / release=production) +- Sign in with Apple `Default` +- iCloud KVS `$(TeamIdentifierPrefix)com.osgkeyboard.ios`(**单字符串**,非数组 —— 数组会破坏自动签名) +- 音频输入 +- 关联域名 `applinks:osglab.com` + +**StoreKit 配置**(`OSGKeyboard.storekit`):4 个消耗型 —— 自愿打赏 `ByRockyACoffee` (¥28) + 3 档账户积分 `500tks/1500tks/3000tks` (¥8/¥18/¥28)。**无订阅**。 + +### 1.3 iOS HostSupport 框架 (`OSGKeyboardHostSupport/`) + +设计原则:**唯一**链接 `Speech / AVFoundation / Charts / StoreKit` 的 iOS 框架——键盘扩展**不**链接它,把 jetsam 预算留给 Shared。 + +**子目录**: + +| 路径 | 内容 | +|---|---| +| `Services/` | ASR + 云 ASR + Flow 捕获 + Dictation + Tip + `CloudASR/` + `Tip/` | +| `Models/` | `AudioBufferSnapshot+AVFoundation.swift` —— `AVAudioPCMBuffer` ↔ 共享 `AudioBufferSnapshot` 适配 | +| `DesignSystem/` | `SevenDayUsageChart` + `UsageStatsCluster` + `SupportDeveloperSection` | +| `Features/Account/` | `AccountAPIClient` / `SignInWithApple` / `DeviceIntegrity` (App Attest) / `HostPrivateAccountKeychain` / `AccountModels` / `OOBEGrantProvisioningCoordinator` | + +**关键 Services**: + +- **`ASRService.swift:1`** —— 公共协议 `transcribe(stream:locale:)` 返回 `AsyncStream` (`.capability/.partial/.final/.error`);`transcribeChunk(samples:locale:)` 用于流水线 Flow 路径。`ASRServiceFactory.make(store:)` 返回 `SpeechAnalyzerASR`(本地)或 `CloudASRService`(云)。`SpeechAnalyzerASR` 用 **iOS 26 `SpeechAnalyzer + DictationTranscriber` + bundled `SFCustomLanguageModelData`**,锁用 `OSAllocatedUnfairLock`,支持流水线 chunk 复用。 +- **`CloudASRService.swift:1`** —— 云 ASR 前门。`CloudASRClientFactory.make(...)` 返回 8 个客户端:`ZhipuCloudASRClient` / `AlibabaFunASRClient` / `BailianRealtimeASRClient` / `VolcengineCloudASRClient` / `OpenAIRealtimeASRClient` / `PromptCloudASRClient` / `ManagedVolcengineASRClient` / `UnsupportedCloudASRClient`。 +- **`LiveDictationController.swift:1`** —— `@MainActor ObservableObject`,阶段机 `idle/recording/processing/denied/error`,自管 `AVAudioEngine + AVAudioSession`。生产键盘**不**用它,**生产用 `FlowSessionManager + FlowContinuousCapture`**。 +- **`FlowAudioSessionCoordinator.swift:1`** —— 进程级 `AVAudioSession` + Flow 的 `AVAudioEngine` 拥有者。`.voiceChat` 模式。 +- **`CustomLanguageModelManager.swift`** —— 单例,准备 bundled `OSGKeyboardCLM.bin` 写进 App Group。指数退避 30s/120s/600s。**重到 `MainAppRoot.scheduleCLMWarmup` 延后 45s 启动,Flow 忙时拒绝运行**。 + +**HostSupport 持久化**: +- CLM 编译产物 → App Group container +- Account 令牌 → host-only keychain(**不**走 App Group) +- Managed AI 凭证 → 单独 `GatewayGrantKeychainStore`;**只**镜像"会话可用"布尔进 App Group +- Tip 计数 → 标准 `UserDefaults` + +### 1.4 iOS Keyboard Extension (`OSGKeyboardExt/`) + +**入口**:`KeyboardViewController.swift:33-38` —— `@objc(KeyboardViewController) @MainActor` `UIInputViewController`。**单一** `KeyboardState` ObservableObject。 + +**生命周期(`KVC.swift`)**: +- `init` → 启动 `KeyboardExtensionMemoryTelemetry`(warning 36MB / safe 40MB / critical 48MB) +- `viewDidLoad` (`:148-207`) → 设 `showsSystemGlobeKey = isPad`、`primaryLanguage = "mis"`(隐藏"English"副标题误导),读 `TypingInputConfiguration.preferredSurfaceOnOpen()`,调 `refreshLayoutMode() + installKeyboardHeight() + configureDictationBehavior() + installServices() + installSwiftUI()` +- `viewWillAppear` (`:256-293`) → 标记 `KeyboardSetupBridge.markExtensionAppearance(hasFullAccess:)`、刷新 Flow/config、同步 onboarding、准备 `KeyboardHapticFeedback` generators +- `viewIsAppearing/viewDidAppear` (`:295-356`) → 锁高度 `lockPresentedKeyboardHeight()`,禁系统手势延迟,记录 analytics +- `viewWillDisappear` (`:209-254`) → 通知 `AnalyticsExtensionService.keyboardWillDisappear()`、取消 `assistantFieldActionRefreshTask`、使 `editHintScheduler` 失效、持久化最后 surface +- `textDidChange/selectionDidChange` → `refreshReturnKeyRole`、同步 English 文档上下文 +- `didReceiveMemoryWarning` (`:390-404`) → **取消流水线、退出 typing 模式、强制回 voice surface** —— typing 引擎是最大内存消费者 + +**`installServices` (`:419-547`)** 安装的 8 个 coordinator: +- `EditHintScheduler`(mic 上方一句话提示) +- `KeyboardTextInserter`(插入 Flow 转写 / undo / redo / 粘贴剪贴板) +- `KeyboardConfigSync`(App Group 配置 + Darwin observers + onboarding 镜像) +- `KeyboardFlowCoordinator`(Flow start/stop + session monitor + watchdogs) +- `LastInputEditCoordinator`(长按 → 编辑上次输入) +- `AIKeyboardCoordinator`(长按 → AI 模式) +- `ClipboardCaptureCoordinator`(`changeCount` 轮询,secure-field 抑制,建议条) +- `AnalyticsExtensionService` + +**`installStateActions` (`:551-655`)** 装的 30+ action:`beginRecording/endRecording/tapMic/cancelVoiceInput/beginEditLastInput/.../setMode/setLocale/setEngineMode/setTranslationTargetLocaleId/insertNewline/insertSpace/deleteBackward/undoLastInsertion/redoLastInsertion/copySelection/cutSelection/setSurface` + +**手势模型**: +- 长按 push-to-talk → `RecordButtonGesturePolicy` 阈值 `longPressDuration = 0.45s` +- 点击 mic → 切换(`RecordButton.tapAction`) +- 重复删除 → `RepeatingPressButton` + `RepeatingDeleteTiming`(80→50→30→15 ms 加速) +- Shift 长按 → `TypingSessionController.shiftHeld`,双击 capsLock +- 语言切换 → 3-tab `KeyboardInputTab`(assistant / 中文 / 英文) +- 触觉 → `KeyboardHapticFeedback`(5 个 Taptic generator + 4 个按键角色 + `KeyboardHapticIntensity` off/light/strong) +- 声音 → `UIDevice.playInputClick()` + 系统音 1155 (delete),通过 `@retroactive UIInputView: UIInputViewAudioFeedback` 启用点击声 +- 主题 → 始终深色(`Palette.dark` 是 canonical) + +**iOS-only 扩展调用**:`UIInputViewController` / `UIInputView` / `UITextDocumentProxy` / `NSExtensionContext` / `UIPasteboard.general` / `UIImpactFeedbackGenerator` / `UIDevice.playInputClick()` / `AudioServicesPlaySystemSound(1155)` / `UIButton` / `UICollectionView` + `UICollectionViewCompositionalLayout` / `UIHostingController` + +**Host/Extension 边界数据**(`AppGroupPersistor.load(into:)` 读取): +- `providerId` / `baseURL` / `apiKey`(`Keychain.apiKeyOutcome` 解析 shared keychain `$(AppIdentifierPrefix)com.osgkeyboard.shared`) +- `model` / `modeId`(恒为 `polish`)/ `localeId` / `engineMode`(local/cloud) +- `translationTargetLocaleId` / `handednessPreference`(左/右手 delete↔space 互换)/ `clipboardHistoryEnabled` / `clipboardCandidateBarEnabled` +- `keyboardHapticIntensity` / `clipboardSkillSnapshot`(启用技能 ID)/ `apiKeyAvailability` + +**`KeyboardConfigSync` (`KeyboardConfigSync.swift:24-62`)** 安装 4 个 `FlowSessionDarwinObserver`:`session.changed` / `command.changed` / `transcription.changed` / `host.ready.changed` + +### 1.5 iOS Shared Framework (`OSGKeyboardShared/`) + +**目录**: + +| 路径 | 内容 | +|---|---| +| `Constants/AppGroup.swift` | App Group ID 唯一源 | +| `Core/Configuration/` | `ConfigurationStore` 协议 + iOS 实现 `AppGroupStore+ConfigurationStore.swift` + `LiveConfigurationStore` | +| `DesignSystem/` (9 文件) | `Theme` / `RecordButton` / `RecordButtonGesturePolicy` / `WaveformView` / `EditTextPager` / `CardPageLayout` / `SonicParticleField` / `ThemedRoot` / `UsageStatCard` / `UsageSurfaceCard` | +| `Features/Analytics/` (10 文件) | `AnalyticsClient` / `AnalyticsRepository` (SQLite) / `AnalyticsUploadCoordinator` / `KeyboardUsageRepository` (跨进程 SQLite WAL) / ... | +| `Features/ManagedGateway/` (8 文件) | `ManagedLLMClient` / `GatewayGrantCoordinator` (actor) / `GatewayGrantCredentialStore` / `ManagedGatewayAccountAccessPolicy` / `ManagedGatewayScopePolicy` / `ManagedGatewayQuestionRouter` / `ManagedGatewayModels` / `OOBEGatewayGrantCoordinator` | +| `Localization/SharedL10n.swift` | `NSLocalizedString` against `Shared.strings`;解析 `AppUILanguage` | +| `Models/` (~50 文件) | 见下 | +| `Resources/` | `ClipboardSemantics/{*.mlmodel,*.json}` (6 CoreML) / `PolishStyles/{manifest.json,builtin.*.json}` (10 内置样式) / `Typing/English/{english_lexicon.{bin,tsv},english_bigrams.tsv}` | +| `Services/` (~80 文件) | 见下 | +| `Typing/` (24 文件) | librime + 拼音 + 英文 autocorrect + 触屏 + 布局 | +| `Utilities/` (21 文件) | 日志、错误模型、keychain、locale、Han 脚本、PCM/WAV、内存预算等 | + +**关键 Services 详解**: + +- **`PolishingService.swift:34-105`** —— `public actor`。`polish(_ raw:mode:systemPrompt:context:) -> PolishOutcome(text:qualityDegraded:polishStyleID:polishStylePrompt:)`。**单一融合 LLM 调用**:T1 自纠 → T2 填料清理 → T3 同音纠正 → T4 标点 → T5 结构 → 应用样式。Fun personality 在 heavy 强度切到"仅格式"共享核心。 +- **`LLMClient` 协议** (`Services/LLMClient.swift:91-150`) —— `polish(_:systemPrompt:timeout:)` + `complete(messages:tools:timeout:options:)`。实现:`LLMClientFactory`(OpenAI 兼容)、`AnthropicLLMClient`(Anthropic Messages API)、`ResponsesAPILLMClient`(OpenAI Responses)、`ManagedLLMClient`(账户 grant 范围)、`AIModeSearchFallbackClient`(搜索→纯)。 +- **`Keychain.swift:15`** —— `kSecClassGenericPassword` 读写 + iCloud 同步变体 + `OnFirstUnlockThisDeviceOnly` 本地变体 + XCTest 内存回退。 +- **`AIClipboardSkill.swift:44`** —— 技能模型:`id` / `systemImage` / `titleKey` / `kind`(`.direct/.transform/.export`)/ `isDefault` / `shortcutName` + iCloud 分享 URL + bundled `.shortcut` 资源 / `thinkingEnabled` / `customName/Summary/Prompt` / `requiresShortcut` / `isUserCreated` / `isOfficial` / `managedGatewayTaskKind` / `supportsReplyStyle`。 +- **`ClipboardHistoryStore.swift:10-84`** —— App-Group-backed observable store。合并通过 `ClipboardHistoryPolicy`(去重、清洗、保留)。 +- **`ClipboardSemanticAnalyzer.swift:1-58`** —— 本地:`NLTokenizer/NLTagger` + 6 CoreML 分类器(`ClipboardSemantics/*.mlmodel`)产生 `language/dates/addresses/phoneNumbers/urls/personNames/organizationNames/sentiment/task/question/invitation/complaint/replyableMessage` 意图。 +- **`ClipboardSkillSemanticRanker.swift:11-66`** —— 用语义分析 + 偏好语言对完整目录排序,固定 generic Reply 兜底。Stateless / `Sendable`。 +- **`SpeechHistoryStore.swift:10-78`** —— iCloud-KVS 镜像 observable,prompt-fingerprint dedup。 +- **`LocalASRModelManager.swift:11`** —— macOS Qwen3-MLX 权重 Application-Support 安装管理器。状态持久化到 `installed-manifest.json`。**Mac 专用**(iOS 用系统 SpeechAnalyzer)。 +- **`LocalASRBiasAdapter.swift:9`** —— 分层偏差:`PersonalDictionary.effectiveEntries` + 内置 `BuiltinLexiconIndex.topTerms(...)`;按前台 App bundle id 选 `builtin-computer` vs `builtin-top`(Xcode/VSCode/Android Studio/IntelliJ/AppCode/Sublime/Terminal/iTerm2/Warp)。 +- **`LocalASRTranscriptCorrector.swift:9-65`** —— 确定式 alias→canonical 替换,最长匹配优先,ASCII whole-word 感知。 +- **`PolishStyleLearningService.swift:64`** —— `build(from:)` 从 `SpeechHistoryEntry` 历史生成 `PolishStyleLearningCorpus`(5000 字符 `requiredEffectiveCharacterCount`)。 +- **`AIQuestionService.swift:20`** —— `actor AIConversationStore` 保留 `retainedConversationRounds` 轮 / 对话。 +- **`AIEventExtraction` / `AIAddressExtraction` / `AITodoExtraction`** —— 解析 LLM 输出为 `start|end|title|location` / `origin|destination` / 列表。**全部 fail-closed**。 +- **`HostAppURLRegistry`** —— 白名单 deep-link 路径(`deployrime`、`settings/clipboard`、`skill/run` 等),host + ext 共用。 +- **`KeyboardSetupBridge`** —— `markExtensionAppearance(hasFullAccess:)` + OOBE 实践会话标志。 +- **`AppGroupConfigDarwin`** —— `CFNotificationCenter` 发 `com.osgkeyboard.config.changed`(host 写完后)。 +- **`FlowSessionBridge` + `FlowSessionKeys`** —— 跨进程 mailbox 全部 App-Group key:`flowHostReady` / `flowHostReadyAt` / `flowHeartbeat` / `flowSessionActive` / `flowSessionExpires` / `hostHeavy/At` / `hostGeneration` / `transcriptionResult` / `transcriptionPartial` / `transcriptionError{Kind,PolishWarning}` / `pendingKeyboardUtteranceId` / `lastPiPArmAttemptAt` / `flow.commandPayload.v1` / `flow.commandJournalPayload.v2` / `flow.resultPayload.v1` / `flow.ackPayload.v1` / `flow.startTransaction.v1` / `flow.readyPayload.v1`。 +- **`SettingsCloudSync` / `SpeechHistoryCloudSync` / `UsageStatisticsCloudSync` / `AppCloudSync`** —— KVS 镜像;`ICloudSyncPreferences` + `SyncDeviceID` 保留每设备身份。 +- **`AIHintStore` + `AIHintPool` + `AIHintKeywordExtractor` + `AIHintKeywordCompressor` + `AIHintLocalCatalog`** —— 闲置热词轮播:App-Group-pack loader、合并远端 + 本地卡片、确定性 10 或 22 字符关键词抽取(zh/en)。 +- **`TranscriptionPolishFallback.swift`** —— LLM 润色失败或跳过时,交付原始转写 + 软警告。 +- **`WhatsNewDemoScenario`** —— "What's New" 时间轴 driver,host(peek/consume)和 ext(DEBUG)共用。 + +**Polish 子系统详细**: +- `PolishContext` (`Models/PolishContext.swift`):appContext + precedingText + followingText + fieldHints + dictionarySupplement + maxPrecedingChars=600 + maxFollowingChars=200 +- `AppContext` 5 种(code/email/chat/document/unknown) +- `AppContextDetector` (`:30-116`):3-fallback 链——`textDocumentProxy.documentContextBeforeInput` 启发式(iPad 沙盒安全;键盘读不到前台 bundle id)→ 30 分钟缓存 → 环境 blend +- `PolishStylePack` (`Models/PolishStylePack.swift:9-87`):`Codable, Equatable, Identifiable, Sendable`;`id/name/prompt/allowsAddedEmoji/kind(.builtin/.user)/createdAt/updatedAt` +- `PolishStyleLimits`:`maximumUserPacks = 8` / `maximumPromptCharacters = 6_000` +- 内置样式分两组:`.practical`(light/structured/formal/chat)+ `.fun`(dating/flex/corp/diba/xhs) + +**Typing 子系统**(`Typing/`): +- `LibrimeEngine.swift` —— `@MainActor` 中文 IME,部署 3 套 schema:`osg_pinyin` / `osg_double_pinyin_mspy` / `osg_double_pinyin_sogou` +- `EnglishSuggestionEngine.swift` —— 3 槽 QuickType(verbatim / correction / completion),`inVocabularyFrequencyGap = 250` +- `EnglishLexicon.swift` —— mmap 40k 词 `english_lexicon.bin` +- `EnglishSystemLexicon.swift` —— `#if canImport(UIKit)` 守卫包 `UITextChecker + UILexicon` +- `EnglishQWERTYProximity.swift` —— 空间编辑代价 +- `TypingSessionController` —— 拥有 typing surface:language/page/shift state、English 引擎、librime 引擎、拼音/英文消歧、autocap、句号快捷、shadow preceding text、`supplementaryWords`、hot reload +- `TypingSurfaceMetrics` (`Models/TypingSurfaceMetrics.swift`) + `KeyboardChromeLayout` —— 单一源:键行指标、底部动作行分数、总高 281pt,iPad narrow/wide(narrow 54pt / wide 76pt),`wideIPadWidthThreshold = 1100` +- `TypingInputConfiguration` —— `TypingInputSchema` (3 case) + `DefaultInputMode` (voice/pinyin/english) +- `PeriodShortcut.swift` —— iOS 句号快捷:letter→number 后第二个空格 0.45s 内变 ". " + arm sentence Shift +- `TypingAutocapitalization` —— 镜像 `UITextAutocapitalizationType` + +**Models (~50 文件) 关键**: +- `AppGroupConfiguration` —— 完整 Codable blob +- `SyncedAppSettingsV2` —— per-field `SyncedField` LWW + broken-clock containment (6h skew horizon) +- `LLMProvider` —— 8 家云预设 +- `AIUserSkill` / `AIAgentSkillLayout` / `OfficialSkillCatalog` / `OfficialSkillDefinition` —— 技能体系 +- `PolishStylePack` / `PolishStyleCatalog` / `PolishStyleLearningCorpus` +- `PersonalDictionary` / `+Merging` / `+ASRBias` —— 跨设备词典,iCloud 合并;提供 `hotwords` / `asrPromptBias` / `alibabaHotwordEntries` / `vocabularySyncFingerprint` +- `ClipboardHistoryEntry` / `SpeechHistoryEntry` / `SyncedSpeechHistory` +- `PolishContext` / `FieldHints` / `AppContext` / `AppUILanguage` +- `ProviderConfig` / `CloudProviderRole` / `CloudASRModels` / `LocalASRModelCatalog` / `LocalASRCapabilities` / `LocalASRBiasPayload` +- `TypingInputConfiguration` / `TypingSurfaceMetrics` / `KeyboardChromeLayout` +- `HandednessPreference` / `KeyboardHapticIntensity` / `PolishIntensity` / `AIResponseLength` +- `FlowUtteranceRequest` / `FlowUtteranceMode` / `FlowUtteranceChunkConfig` / `FlowHandoffPolicy` / `FlowAck` / `FlowResult` / `FlowCommand` / `FlowReadySnapshot` / `FlowStartTransaction` / `FlowTranscriptionError` / `FlowFieldContext` +- `MicVoiceAvailability` / `+Keyboard` —— mic 状态 + 派生 +- `EditableInputReference` —— 跨进程最后插入引用(10 min TTL、≤1200 graphemes、schema v1) +- `EditSessionState` —— 关闭状态机 `.inactive/.preparing/.listening/.processing/.review/.applying/.appending/.failed` +- `AISessionState` —— `.inactive/.idle/.preparing/.listening/.recognizing/.generating/.ready/.awaitingSend/.inserted/.sent/.failed` + +**Utilities (21 文件)**: +- `OSGLog.swift` —— subsystem + 类别(`flow/clm/config/asr/keyboardExt`) +- `OSGDiag.swift` —— NSLog + 内存快照(`task_info`) +- `FlowTrace.swift` —— `[trace] stage=...` +- `FlowPipelineDiagnostics.swift` / `FlowCaptureTailDrain.swift` / `FlowUtteranceEndCoordinator.swift` / `FlowUtterancePCMStore.swift` +- `UtteranceStreamChunker.swift` / `UtteranceBatchFallbackPolicy.swift` / `UtteranceTranscriptGuard.swift` / `UtteranceTranscriptStitcher.swift` / `TranscriptOverlapUtilities.swift` / `TranscriptLanguageDetector.swift` +- `ProgressiveDictationTranscriptAccumulator.swift`(**Mac 排除**) +- `DictationTextComposer.swift` / `FinalChunkRecovery.swift` / `PCMSampleWavEncoder.swift`(mono Float32 @16kHz → WAV) +- `PromptXMLEscaping.swift` / `HanScript.swift`(BMP 汉字谓词) +- `HostMemoryBudget.swift` / `KeyboardExtensionMemoryTelemetry.swift`(**Mac 排除**) +- `ProviderDisplayName.swift` / `AppVersionDisplay.swift` + +**iOS-only 排除(`project.yml:570-586`)**: +``` +DesignSystem/WaveformView.swift +DesignSystem/RecordButton.swift +DesignSystem/RecordButtonGesturePolicy.swift +Services/KeyboardState.swift +Services/KeyboardOpenSurfacePolicy.swift +Models/MicVoiceAvailability+Keyboard.swift +Models/TypingInputConfiguration.swift +Models/TypingSurfaceMetrics.swift +Typing/** +Utilities/ProgressiveDictationTranscriptAccumulator.swift +``` + +### 1.6 iOS 测试覆盖(108 + 16 文件) + +**`OSGKeyboardTests/` (108 文件)** —— host + shared + hostsupport 端到端: +- **ASR/云 ASR**:`CloudASRServiceTests` / `CloudASRHTTPClientTests` / `CloudASRStreamingHelpersTests` / `CloudASRStreamingEventParsingTests` / `CloudASRTests` / `ASRConversionTests` / `LocalASRModelCatalogTests` / `LocalASRDownloadSourceSorterTests` / `LocalASRBiasAdapterTests` / `AlibabaVocabularySyncTests` / `FrequentTermStoreTests` / `PreviewASRControllerStateTests` +- **语音流水线**:`VoicePipelinePerformanceTests` / `FlowPhysicalAudioStressTests` / `FlowReliabilityTests` / `FlowASRPostProcessorTests` / `ChunkedUtterancePipelineTests` / `FlowBudgetAndMergeTests` / `FlowCaptureTailDrainTests` / `FlowUtteranceEndCoordinatorTests` / `FlowUtterancePCMStoreTests` / `UtteranceBatchFallbackPolicyTests` / `UtteranceStreamChunkerTests` / `UtteranceTranscriptGuardTests` / `UtteranceTranscriptStitcherTests` / `ProgressiveDictationTranscriptAccumulatorTests` / `FlowSessionBridgeTests` / `FlowSessionManagerAnalyticsTests` / `FlowSessionPolicyTests` / `FlowHandoffPolicyTests` / `FlowPiPRecoveryPolicyTests` / `FlowStartTransactionPolicyTests` / `FlowHomePiPStatusPolicyTests` / `FlowKeyboardPoliciesTests` / `KeyboardExtensionMemoryBudgetTests` +- **Polish/LLM**:`PolishStylePackTests` / `PolishStyleLearningServiceTests` / `PolishPromptComposerQuestionTests` / `PolishOutputValidatorTests` / `IntelligentPolishTests` / `LLMClientTests` / `AIModeLLMClientTests` +- **AI 特性**:`AIQuestionServiceTests` / `AIEventExtractionTests` / `AIAddressExtractionTests` / `AINoteExportTests` / `AIHintKeywordExtractorTests` / `AIHintPoolTests` / `AIUserSkillTests` / `AIUserSkillStoreTests` / `AIAgentSkillLayoutTests` / `AIHistoryAndUsageTests` / `AssistantFieldActionTests` / `AISessionStateTests` / `AIClipboardPromptTests` / `AnalyticsAIOperationTests` / `AppleNaturalLanguageCapabilityTests` / `SpeechHistoryRevisionTests` / `SpeechHistoryDayDeletionTests` / `SpeechHistoryCloudSyncTests` / `PublicContentRefreshServiceTests` +- **剪贴板/工具**:`ClipboardSkillSemanticRankerTests` / `ClipboardSemanticAnalyzerTests` / `ClipboardHistoryStoreTests` / `ClipboardHistoryPolicyTests` / `AccountCenterViewModelTests` / `AccountSnapshotLoaderTests` / `AccountAPIClientTests` / `AccountSignInCoordinatorTests` / `AccountCreditPurchaseManagerTests` / `AccountSecurityPrimitiveTests` / `ReferralProfileTests` / `EditTransactionStoreTests` / `EditableInputReferenceTests` / `EditLastInputPromptTests` +- **iCloud 同步**:`PersonalDictionaryCloudSyncTests` / `PersonalDictionaryMergeTests` / `SettingsCloudSyncTests` / `UsageStatisticsCloudSyncTests` / `KeyboardUsageRepositoryTests` / `KeyboardUsageModelTests` / `KeyboardUsageUploadCoordinatorTests` / `AnalyticsUploadCoordinatorTests` / `AnalyticsUploadSchedulingTests` / `AnalyticsRepositoryTests` / `AnalyticsModelTests` / `AnalyticsAttributionTests` +- **本地 ASR + 内存**:`LocalASRModelCatalogTests` / `KeyboardExtensionMemoryBudgetTests` +- **设置 + 工具**:`AppGroupConfigurationTests` / `AppGroupOnboardingStoreTests` / `ConfigurationStoreTests` / `HostAppURLRegistryTests` / `KeyboardTranslationConfigProtectionTests` / `KeychainTests` / `DeviceIntegrityTests` / `MicVoiceAvailabilityTests` / `OpenSourceLicenseCatalogTests` / `TipProductTests` / `EnglishTypingOnDeviceTests` / `TranscriptLanguageDetectorTests` +- **账户/Managed gateway**:`AccountAPIClientTests` / `AccountSignInCoordinatorTests` / `AccountCenterViewModelTests` / `AccountCreditPurchaseManagerTests` / `AccountSecurityPrimitiveTests` / `AccountSnapshotLoaderTests` +- **测试支持**:`AnalyticsTestSupport` / `KeyboardUsageTestSupport` / `FakeUbiquitousKeyValueStore` + +**`OSGKeyboardExtTests/` (16 文件)**: +- `EnglishTypingTests` / `RimePersonalDictionaryExporterTests` / `KeyHitTestingTests` / `CandidatePanelExpandTests` / `KeyboardUsageTypingTests` / `LibrimeIntegrationTests` / `ManagedGatewayTests` / `RimeSchemaGeneratorTests` / `KeyboardSurfaceStateTests` / `AnalyticsExtensionPrivacyTests` / `PinyinNextKeyResolverTests` / `TypingTouchTrackerTests` / `FinalChunkRecoveryTests` / `KeyboardStateTests` / `EditHintSchedulerTests` / `ClipboardSuggestionLifecycleTests` + +--- + +## 2. macOS 端完整状态 + +### 2.1 目标基本事实 + +- Bundle ID: `com.osgkeyboard.mac`(Developer ID + 公证,**沙盒关闭**) +- 最低系统:macOS 15.0。Swift 6 / strict concurrency +- 装包名:`OSGKeyboard.app`(target 名 `OSGKeyboardMac` 改 `PRODUCT_NAME`) +- 本地 ASR:**MLX Audio Qwen3-ASR 0.6B/1.7B 4-bit**(`ThirdParty/mlx-audio-swift/` SPM) +- 复用 iOS AppIcon (`OSGKeyboard/AppIcon.icon`) + iOS 资产目录(除 `AppIcon.appiconset`) + +### 2.2 macOS App 目录结构(`OSGKeyboardMac/`,39 Swift 文件,无子目录) + +**入口与生命周期**: +- `OSGKeyboardMacApp.swift:1` —— `@main struct OSGKeyboardMacApp: App`。**单一** `Window`(`.windowStyle(.hiddenTitleBar)`、`.defaultSize(width: 860, height: 600)`)+ `MacAppDelegate: NSApplicationDelegate + NSPopoverDelegate` 拥有 `NSStatusItem + NSPopover`(340×420 transient)+ 浮动 dictation overlay +- **不**用 `MenuBarExtra`(`OSGKeyboardMacApp.swift:104-105` 注释:与主 Window 同存时图标会消失) +- 订阅 `Notification.Name`(`.settingsDidSyncFromCloud` / `.personalDictionaryDidSyncFromCloud` / `.usageStatisticsDidSyncFromCloud` / `.speechHistoryDidSyncFromCloud`)iCloud pull 刷新 +- `onOpenURL` 接 `osgkeyboard://seed-demo`(DEBUG `DemoDataSeeder`) + +**侧栏/Shell**: +- `MacRootView.swift` —— `NavigationSplitView(.balanced)`,侧栏:`OSGLogoWide` 品牌头 + 5 个 `MacSection` 行(`.dashboard/.history/.dictionary/.styles/.settings`)+ "Devices" 页脚;detail 切换 `DashboardView/MacHistoryView/MacDictionaryView/MacPolishStylesView/MacSettingsView`;底部 `MacStatusFooter` +- `MacDictationViewModel.swift:12` —— `MacSection` enum +- `MacTheme.swift:114` —— `MacSystemPalette` 双模式(light 暖白 + dark stepped systemGray6→4),通过 `\.themePalette` 注入,颜色方案变更重渲染 +- `MacAppearance.swift` —— `MacAppearancePreference`(system/light/dark)存 `mac.appearancePreference`,`applyToApp` 推到 `NSApp.appearance` 和每个 window + +**菜单栏 UI**(Status Item + Popover): +- `OSGKeyboardMacApp.swift:140` —— variable-length `NSStatusItem` 模板 NSImage,target/action = `togglePopover(_:)` +- Popover 内容:`NSHostingController(rootView: MacMenuBarPopover())` (`:214`)。未完成 onboarding → "open the main window" 提示;否则 `MacContentView`(品牌 + record 按钮 + 热键提示 + 状态文本 + 滚动转写 ≤120pt + 模式/翻译/连接状态 + footer) +- Popover 打开:`prepareForPopoverPresentation()` snapshot 前台 App,**OSGKeyboard 成为 key 时不丢失粘贴目标** + +**录制 overlay**: +- `MacDictationOverlayController.swift` —— 一个 borderless non-activating `NSPanel`(level `floatingWindow + 1`、`.canJoinAllSpaces + .fullScreenAuxiliary + .stationary`) +- 状态机驱动 show/hide(`viewModel.$isRecording/$isPreparingToRecord/$isProcessing` CombineLatest3) +- 位置 user-draggable(双击重置底部居中),持久化到 UserDefaults(`mac.overlay.hasCustomPosition / centerX / originY`) +- `MacDictationOverlayView.swift` —— SwiftUI pill(500pt 内容宽)+ 状态点 + 一行转写 + 实时徽章 + `MiniWaveform` + stop 按钮 +- `MiniWaveform` 复用 `OSGKeyboardShared/DesignSystem` + +**主窗口 5 大页**: +- `DashboardView.swift` —— `GeometryReader` 垂直布局:品牌头 + 7 天图(`UsageStatsCluster layout: .split`)+ `dictationStage` 卡片(`viewModel.homePreviewText` 累计预览)+ `BottomDictationBar`(翻译选择器 + record 按钮 + ready chip) +- `MacHistoryView.swift` —— 按天分组 `SpeechHistoryStore`,每行 copy/delete context menu,"Clear" 带确认 +- `MacDictionaryView.swift` —— `PersonalDictionary` 按 `Entry.Category` 分组,按使用次数 + 术语排序;搜索框过滤;`+` 弹 `MacDictionaryEntryEditor` sheet +- `MacPolishStylesView.swift` —— `PolishStylePack` 目录(内置 practical+fun + 用户),只读 prompt 详情 / 完整编辑器(`allowsAddedEmoji` 切换 + 2400 字符 prompt 限制);学习卡:语料进度 + "Generate learned style" → `PolishStyleLearningService.generateStyle` +- `MacSettingsView.swift` —— `NavigationStack + ScrollView` 7 段:support developer (Tip) / general (appearance / interface language / recognition language / iCloud sync) / recognition method (cloud vs local) / cloud ASR provider / local ASR model catalog / polish provider / input (hotkey / auto-paste / accessibility 状态) / legal (Privacy Policy + Third-Party Licenses + Restart Onboarding + version) + +**Onboarding**: +- `MacOnboardingView.swift:21` —— 6 步:`welcome → microphone → accessibility → engine → [.cloudAPI | .localModel]`,根据 `viewModel.config.engineMode` 分支 +- Mic 步调 `AVCaptureDevice.requestAccess(for: .audio)`;accessibility 步打开 `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility` 并 1s 后重查 `AXIsProcessTrusted` +- Cloud 步收 LLM provider + key + model;local 步显示默认 Qwen3-MLX 0.6B + 下载按钮 + 进度 + "skip" 提示 +- 状态存 `mac.hasCompletedMacOnboarding` +- 注释 `MacOnboardingView.swift:7-8` —— "Sherpa identifiers and install records are retained only for migration compatibility":**近期从 Sherpa 换到 MLX** +- Settings → About 有 "Restart Onboarding" 按钮 + +**Legal / Support**: +- `MacLegalSettingsViews.swift` —— `MacPrivacyPolicyView`(`WKWebView` 加载 `OSGKeyboard/Resources/PrivacyPolicy.html`,按 `uiLanguage` 切滚动锚点)+ `MacOpenSourceLicensesView`(`OpenSourceLicenseCatalog.entries(for: .macOS)` 列表)→ `MacOpenSourceLicenseDetailView`(许可证名 / upstream 链接 / 用途 / 文本) +- `MacSupportDeveloperTipRows.swift` —— StoreKit 2 `TipPurchaseManager.shared` 消耗型打赏行 + +**DEBUG 工具**: +- `osgkeyboard://seed-demo` URL 触发 `DemoDataSeeder.seedRichPlaceholderData` +- `MacAudioRecorder.hasLiveSnapshotSink`(DEBUG seam,见 `MacAudioRecorder.swift:143`) + +### 2.3 全局热键 + 辅助功能 + 粘贴注入 + +**`MacHotkeyService.swift`**: +- 用**两个** `NSEvent.add*MonitorForEvents(matching: .flagsChanged)`(global + local) +- 区分左/右 Option(`NX_DEVICELALTKEYMASK = 0x20` / `NX_DEVICERALTKEYMASK = 0x40`);`MacHotkeyTrigger`(`rightOption / leftOption / eitherOption`) +- 150ms 持有防抖(`scheduleBegin` → `Task.sleep(150ms)` → fire `onPressBegan`),松开取消未发起的 begin +- `start()` 返回 Accessibility 状态(`globalFlagsMonitor == nil` ⇒ 大概率无权限),DEBUG 下 NSLog + +**`MacTextInsertionService.swift`** —— Accessibility 门控合成 ⌘V: +- `AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt: true])` 按需 +- `insert(_:autoPaste:targetApp:)`:清 `NSPasteboard.general` → 写转写 → snapshot 原始 pasteboard(**所有 representation,不仅是 `.string`**)→ `CGEvent + kVK_ANSI_V + cghidEventTap` 合成 ⌘V → 等 500ms 后**仅当 change count 仍匹配 post-transcript write**才恢复 snapshot(用户/clipboard-manager 写入期间不覆盖) +- `FrontmostAppTracker`(`:177`)跟踪 `NSWorkspace.didActivateApplicationNotification`,**OSGKeyboard popover 激活时**保留原始 paste 目标 +- `activate(_:)` 重新激活目标 App,等最多 1s 成为 frontmost +- `shouldRestorePasteboard` + `restoreItems` 是单测 seam + +### 2.4 音频捕获 + +**`MacAudioRecorder.swift`**: +- `MacAudioRecording` 协议 +- **`AVAudioEngine.inputNode`** + 4096 帧 tap +- 输入格式 → 16kHz mono Float32 via `AVAudioConverter` +- 10 分钟 @16kHz 硬上限 + 30s trim hysteresis(卡住的热键不会无限增长 buffer) +- RMS 平滑(attack 0.5 / decay 0.15) +- 权限:`AVCaptureDevice.requestAccess(for: .audio)`;拒/受限抛 `microphoneAccessDenied` +- `makeSnapshotStream() -> AsyncStream` +- Sink 安装按 generation 追踪,避免前一 sink 延迟终止处理器卸载新 sink +- **无输入设备选择器**(`AVAudioEngine.inputNode` 用系统默认;iOS `AudioBufferSnapshot+AVFoundation.swift` 在 `project.yml:568` 排除) + +**`MacHallucinationFilter.swift`**: +- 剥 `` 脚手架、`language XX` 前缀、元数据噪声行(`language/emotion/event` 等) +- `silencePeakThreshold = 0.0005`(RMS 低于此跳喂 MLX stream) +- `shouldDiscardHotwordDump`(音频能量低 + 转写主要是词典热词 → 丢假触发) + +### 2.5 MLX / 本地 ASR 流水线 + +**关键文件**: +- `MacLocalASRService.swift` —— Mac 端公共面:`selectedModelDefinition()` / `isModelInstalled(_:)` / `usesMLXLiveStreaming()` / `transcribe(samples:locale:bias:)`。MLX 后端模型走 `MacMLXStreamingASRProvider`;`.appleSpeech` 后端或未装模型时落 `MacSpeechLocalASR`。Legacy Sherpa 标识符(`sherpaQwen3/sherpaSenseVoice/sherpaParaformer`)只接受以便抛 `qwen3ModelMissing` —— **明确迁移期处理** +- `MacMLXStreamingASRProvider.swift` —— `actor`,拥有单 `Qwen3ASRModel` 缓存(`cachedModelId / cachedModel / didWarmup`)。`loadModel` 从 `LocalASRModelInstallState.modelRootURL(definition)` 读并 `Qwen3ASRModel.fromModelDirectory(root)`。`warmupIfNeeded` 用 1s 静音预热 Metal kernel。`makeSession` 构建 `StreamingConfig`(0.5s decode interval、0.2s boundary、1s boundary boost、encoder window overlap 1s、max cached windows 8、`delayPreset: .realtime`、language hint 从 `MacQwen3LanguageHint.from(locale:)`、`context: bias?.promptBias`、`temperature: 0`、512 max tokens/pass、2/2 min-agreement passes、`finalizeCompletedWindows: true`) +- `MacMLXStreamingSession.swift` —— 包装 `mlx-audio-swift` 的 `StreamingInferenceSession`。订阅 `AsyncStream`(`displayUpdate/ended/provisional/confirmed/stats`);`MacHallucinationFilter` 过滤 `display/ended`;`onDisplayUpdate` 转发 `display`;`feed(samples:)` 通过 `FlowCaptureDrainTracker.rms(of:)` 跳过低于 `silencePeakThreshold` 的数据;`stop()` 通过 `CheckedContinuation` 返回最终 `String`;`peakAudioRMS()` 暴露 hotword-dump 守卫用最大 RMS +- `MacMLXLiveCapture.swift` —— Option 持有本地 ASR 编排:建 streaming session,`TaskGroup` 内 2 个并发任务:(1) 等首 `finishSignal`(`draining.withLock { $0 = true }` → `await FlowUtteranceEndCoordinator.awaitTailCapture(policy: .macMLX)`)——**显式 `break` 单信号**,永不结束的 stream 不会挂住 group;(2) 消费 `AudioBufferSnapshot` stream,100ms chunk(1600 samples @16kHz)喂 MLX,draining 时 `drainTracker.noteAudio`。然后喂余量,调 `session.stop()`,跑 `MacHallucinationFilter.shouldDiscardHotwordDump` 丢热词-only 转写 +- `MacSpeechLocalASR.swift` —— Apple Speech 兜底。16kHz PCM 写临时 WAV(`PCMSampleWavEncoder`),`SFSpeechURLRecognitionRequest` 强制 `requiresOnDeviceRecognition`(locale 无 on-device 模型时立即失败 → 清晰错误"去 系统设置 → 键盘 → 听写 下载")。Chinese locale 应用 `CustomLanguageModelManager.applyCustomLanguageModel(to:locale:bias:)`(与 iOS 共用)。2× 音频长硬超时 + 30s floor,`RecognitionSession` 锁保护单次 resume,识别回调和超时 task 正确竞速 +- `MacCloudASRChunkAdapter.swift` —— 薄 `ASRChunkTranscribing` 适配器,包装 `CloudASRClientFactory.make(store:)` 给 `MacDictationPipeline` 用 + +**模型目录**(`OSGKeyboard/Resources/LocalASR/local-asr-catalog.json`,2.0.1 状态): +- `schemaVersion: 1` +- `defaultModelId: "qwen3-mlx-0.6b-4bit"` +- `runtimes: []`(**空**——runtime 随 bundle 走,MLX 内置) +- 2 个模型: + 1. **`qwen3-mlx-0.6b-4bit`** —— Qwen3-ASR 0.6B,`backend: mlx`,`runtimePlatform: macos`,`sizeBytes: 730000000`(~0.7 GB),locale `zh-CN/en-US`,支持热词(`promptOnly`),`badgeKey: mac.localASR.badge.balanced`,路径 `models/qwen3-mlx-0.6b-4bit`。两个下载源(`hfmirror`/`huggingface`)都在 `mlx-community/Qwen3-ASR-0.6B-4bit`。文件:`config.json` / `generation_config.json` / `preprocessor_config.json` / `model.safetensors`(~708MB) / `model.safetensors.index.json` / `tokenizer_config.json` / `merges.txt` / `vocab.json` + 2. **`qwen3-mlx-1.7b-4bit`** —— Qwen3-ASR 1.7B,同上形状,`sizeBytes: 1700000000`(~1.7GB),`badgeKey: mac.localASR.badge.quality`。源在 `mlx-community/Qwen3-ASR-1.7B-4bit` + +**`local-asr-catalog.json` 中零 iOS 模型**。iOS app 运行时**不**用本目录——iOS 用 iOS 26 `SpeechAnalyzer + DictationTranscriber`,代码路径不同(`OSGKeyboardHostSupport/Services/ASRService.swift`,Mac 排除)。 + +**模型生命周期**: +- `loadModel(_:)` (`MacMLXStreamingASRProvider` 第 69-81 行) 缓存 loaded `Qwen3ASRModel` per model id。切模型失效缓存并 re-warm。`warmupIfNeeded` 每个冷模型跑一次 +- 卸载(`MacLocalASRModelSettingsViewModel.deleteModel`)→ 共享 `LocalASRModelManager.deleteModel(_:catalog:)` 删磁盘权重;actor 缓存**不**自动清——下次不同 id 的 `loadModel` 自然替换 + +**Mac 不支持 iOS 风格的"on-device + cloud hybrid"**——只有 engine-mode 开关(`local` → MLX 或 Apple Speech;`cloud` → 已配云 ASR)。两种模式共用同一 LLM 润色步。 + +### 2.6 LLM 润色 + 供应商集成 + +- `MacDictationPipeline.swift:254` —— 跑的是**同一份** `PolishingService(store: store).polishWithOutcome(...)`(iOS `OSGKeyboardShared/Services/PolishingService.swift`) +- `PolishContext` 用 `appContext`(从目标 App bundle id 通过 `MacAppContextService` 抓)+ `dictionarySupplement`(来自 local-bias `polishFragment`),**和 iOS 同款** +- `MacSettingsView.swift:111` (`polishProviderSection`) 用 `viewModel.polishSelectableProviders`(`LLMProvider.userSelectablePresets`——**完整集,与 iOS 相同**)。行:provider / API key / baseURL / model(`MacProviderModelRow` "拉模型列表" 动作) / thinking 切换 / `MacProviderToolsRow` "Test connection" / 翻译目标 +- `MacSettingsView.swift:173` (`asrProviderSection`) 用 `viewModel.asrSelectableProviders`(`LLMProvider.asrSelectablePresets`)。Volcengine 有自定义 auth-mode(API key vs App ID + Access Token)通过 `VolcengineASRFields.parse` / `updateMacVolcengine(...)`;其他供应商走通用 `baseURL/apiKey/model` 行 + "Test connection" +- **Mac 没有 Mac 专用 polish/ASR 供应商集**——供应商面和 iOS 一致(DeepSeek/OpenAI/Anthropic 兼容 LLM;Volcengine/OpenAI Realtime/Bailian/Alibaba 云 ASR) +- **MLX 供应商仅用于本地 ASR**——**无** Mac 端 MLX LLM 润色 + +### 2.7 持久化 + +| 类别 | 实现 | Key 前缀 / 位置 | +|---|---|---| +| UserDefaults | 标准 | `mac.*`(`MacHotkeyService:41` / `MacAppearance:56` / `MacDictationViewModel:103-105` / `MacDictationOverlayController:43-45`)—— 热键、auto-paste、appearance、onboarding、overlay 位置、MLX 模型选择、下载源、界面语言 | +| App Group | 共享 `AppGroupStore` | 与 iOS 同:`PersonalDictionary` / `PolishStyleCatalog+active id` / `translationTarget` / `detected appContext` / `speechHistory` / `usageStatistics` / `engineMode` / `localeId` / LLM API key & baseURL / 云 ASR provider id/apiKey/model/baseURL / iCloud-sync-enabled | +| Keychain | 共享 `Keychain` | `keychain-access-groups: [$(AppIdentifierPrefix)com.osgkeyboard.shared]`(entitlement)—— 存 LLM API key | +| iCloud KVS | 5 路全部接好 | `MacICloudSyncBootstrap.swift` 接 `AppCloudSync(makeStore:historyDefaults:)`,暴露 `settingsSync/dictionarySync/polishStyleSync/appCloudSync`,**entitlement 用 `$(TeamIdentifierPrefix)com.osgkeyboard.ios` 和 iOS 同桶** | +| 语音历史 | `SpeechHistoryStore.shared` + `SpeechHistoryStorage` | UserDefaults 后端 + iCloud `SpeechHistoryCloudSync` | +| 模型权重 | `LocalASRModelInstallState.rootDirectory()` | 由共享 `LocalASRModelManager` 管 | +| 临时文件 | `FileManager.default.temporaryDirectory` | Apple Speech WAV(`osg-mac-asr-.wav`,`defer` 删,`MacSpeechLocalASR:71`) | + +### 2.8 系统集成 + +- ✅ 菜单栏 status item + popover +- ✅ 全局 ⌥ 热键 + 合成 ⌘V +- ✅ URL scheme `osgkeyboard`(仅 DEBUG `osgkeyboard://seed-demo`) +- ❌ Login item / launchd(**无** `SMLoginItemSetEnabled`) +- ❌ 通知(`UNUserNotificationCenter`)—— HUD 本身就是唯一直达面 +- ❌ Touch Bar +- ❌ Services / Share extensions +- ❌ Dock badge +- ✅ `LSApplicationCategoryType: public.app-category.utilities` +- ✅ `ENABLE_HARDENED_RUNTIME: YES`(Developer ID + 公证必需) +- ❌ **沙盒关闭** `com.apple.security.app-sandbox: false`(`project.yml:628`)—— Accessibility + 全局事件 tap + ⌘V 注入都禁在沙盒里 +- 发行渠道:**Developer ID + 公证**,**不进** Mac App Store / TestFlight +- `applicationShouldTerminateAfterLastWindowClosed → false`(菜单栏项保活) +- `LSUIElement: false`(**不**设——启动时仍显 Dock 图标) + +### 2.9 macOS 独占(iOS 没有)的能力 + +1. **`NSStatusItem + NSPopover` 菜单栏 UI** —— SwiftUI `MenuBarExtra` 故意不用,AppKit 为源 +2. **全局 ⌥ 持有热键**(`NSEvent.add*MonitorForEvents(matching: .flagsChanged)`)区分左/右/任意 Option +3. **Accessibility 门控 ⌘V 注入**(`CGEvent + cghidEventTap + AXIsProcessTrusted`) +4. **前台 App 跟踪**(`FrontmostAppTracker`)让 popover 激活 OSGKeyboard 时不丢粘贴目标 +5. **MLX Audio Qwen3 流式 ASR**(Qwen3-ASR 0.6B/1.7B 4-bit)通过 `mlx-audio-swift` SPM +6. **系统原生 macOS light/dark 调色板**(暖白 light + stepped gray6→4 dark),从 AppKit semantic colors 解析,让 window chrome 跟随 SwiftUI 颜色方案 +7. **浮动 non-activating `NSPanel` HUD**(`floatingWindow + 1`),user-draggable 位置持久化 +8. **Bundle-ID → `AppContext` 映射**(`MacAppContextService.swift`)—— iOS 走 `AppContextDetector` 启发式(键盘读不到前台 bundle id) +9. **手动 MLX 模型下载/暂停/恢复**,per-file 字节进度 +10. **"Open Storage"** reveal-in-Finder MLX 模型根 +11. **Volcengine 云 ASR auth-mode 切换**(API key vs App ID + Access Token) +12. **同 `URL scheme: osgkeyboard`** 加 `osgkeyboard://seed-demo` DEBUG seed +13. **Apple Speech on-device 兜底**(仅当未装 MLX 模型或 backend == `.appleSpeech`) +14. **`NSApplicationDelegate`-driven AppKit 生命周期**:`applicationShouldTerminateAfterLastWindowClosed → false`、status-item 创建、overlay controller 启动、热键接续 +15. **`MacMLXStreamingASRProvider` actor + 模型缓存 + warmup** 保单 `Qwen3ASRModel` 驻留 +16. **两列 `MacInlinePicker`(供应商行)** vs iOS `.menu` `Picker` +17. **`MacProviderSettingRow` 显式 200pt label 列** 防长凭证输入挤 + +### 2.10 macOS 测试覆盖(`OSGKeyboardMacTests/`,3 文件,挂在 Mac App bundle 内) + +`project.yml:513-533` 覆盖 `@testable import OSGKeyboard`(host target 名 `OSGKeyboardMac` 但装包为 `OSGKeyboard.app`,所以 override `TEST_HOST`)。 + +1. **`MacDictationViewModelTests.swift`** —— `testCancellingButtonPreparationKeepsGateClosedUntilStartUnwinds`:替换 recorder 为 `SuspendedMacAudioRecorder`(`start()` 停 `CheckedContinuation`),验证第二次 `toggleRecording()`(取消)不重开 `isPreparingToRecord` 门 +2. **`MacAudioRecorderSnapshotStreamTests.swift`** —— `testReplacingSnapshotStreamDoesNotDeadlock` + `testReplacingSnapshotStreamKeepsTheNewSinkAttached`:回归守卫,防 Option 松开时 lock 重入冻结(`AsyncStream.Continuation.finish()` 在调用线程同步跑 `onTermination`,handler 重新拿 installer 持有的同 `NSLock` 死锁主线程)。第一个测试在 global queue 装 + 2s semaphore 超时,让回归失败而非挂住套件;第二个测读 `MacAudioRecorder.hasLiveSnapshotSink`(DEBUG seam)确认新 stream 顶替前辈 +3. **`MacTextInsertionServiceTests.swift`** —— `testRestoreRequiresTranscriptToStillOwnPasteboard`(pasteboard change-count 守卫)/ `testRestoringOriginallyEmptyPasteboardClearsTranscript` / `testCapturedBundleIdentifierDrivesPolishContext`(Xcode/WeChat/OSGKeyboard 自身经 `MacAppContextService.detectContext(bundleIdentifier:)`) + +--- + +## 3. macOS 落后 iOS 的功能(按缺口大小排序) + +### 3.1 完全缺失(共享代码已编进 Mac 但零调用) + +| 缺失功能 | iOS 路径 | 共享代码状态 | 影响 | +|---|---|---|---| +| **剪贴板历史 + AI 技能** | `OSGKeyboard/Views/ClipboardSettingsView`、`OSGKeyboardExt/Views/ClipboardKeyboardViews` | `ClipboardHistoryStore` / `ClipboardSemanticAnalyzer` / `ClipboardSkillSemanticRanker` / `AIClipboardSkill` / `AIClipboardSkillLayoutStore` / `AIClipboardPrompt` / `ClipboardHistoryPolicy` **全部编进 Mac 二进制,无任何 Mac 文件引用** | Mac 用户能说话能润色,但**没法**让"复制即智能" | +| **AI 提示轮播 / 上下文技能** | `OSGKeyboard/Services/AIHintRefreshService`、`OSGKeyboardExt/Views/AIKeyboardView` 内的 hint carousel | `AIHintStore` / `AIHintPool` / `AIHintKeywordExtractor` / `AIHintKeywordCompressor` / `AIHintLocalCatalog` / `AIHintModels` 全部已编,**无 Mac 调用** | Mac 没有 idle 屏的"想一句"灵感卡 | +| **OSG 账户(Sign in with Apple + 积分 + 推荐)** | `OSGKeyboard/Views/Account/AccountCenterView`、`AccountPurchaseHistoryView` | `OSGKeyboardHostSupport/Features/Account/` 全部编进 Mac(`AccountAPIClient` / `SignInWithApple` / `DeviceIntegrity` / `HostPrivateAccountKeychain` / `AccountModels` / `OOBEGrantProvisioningCoordinator`),**无 Mac UI 也无调用**,且 Mac entitlement 无 `com.apple.developer.applesignin` | Mac 只能用 tip 打赏;OSG 积分体系是 iOS 独占 | +| **App Attest** | `OSGKeyboardHostSupport/Features/Account/DeviceIntegrity.swift`(`SystemAppAttestProvider`)| 已编,但 Mac 无对应 entitlement(`com.apple.developer.devicecheck.appattest-environment`)也无调用 | Mac 不在 attestation 流程里 | +| **一方分析(Analytics)** | `OSGKeyboard/Services/AnalyticsHostService`、`OSGKeyboardExt/Services/AnalyticsExtensionService` | `OSGKeyboardShared/Features/Analytics/` 12+ 文件全部编进 Mac,**零 Mac 调用** | Mac 用户行为完全无遥测;产品迭代失去数据源 | +| **助手指令(Shortcuts)** | `OSGKeyboard/Services/AIAgentShortcutInstaller`、`AIAgentShortcutRunner`、3 个 bundled `.shortcut`(`OSGExtractTodos/OSGExtractEvents/OSGSaveToNotes`) | 共享 `AIAgentSkill*` / `AIShortcutShareLink` / `AIGenericSkillExport` / `AINoteExport` / `AIMapNavigation` / `AIPhoneNumberActions` 已编,无 Mac 调用;bundled `.shortcut` **Mac 不装**(`project.yml:97-105` 仅 iOS 装) | Mac 没法"说一句把当前文本加到 Notes / 提取 Todo" | +| **CLM 用户管理 UI** | iOS 后台用 `CustomLanguageModelManager` | Mac **用**了 CLM(`OSGKeyboardMacApp.swift:117` 调 `prepareInBackgroundIfNeeded`;`MacSpeechLocalASR:67-98` 调 `applyCustomLanguageModel`),**但**无 `MacCLMSettingsView` | Mac 用户不知道 CLM 在跑;没法改 phrase 库 | +| **最后输入编辑(Edit hint / Last-input edit)** | `OSGKeyboardExt/Views/LastInputEditView`、`EditHintScheduler`、`LastInputEditCoordinator` | 已编,无 Mac 调用 | Mac 没法长按"改刚才那句" | +| **Flow 跨进程会话** | `OSGKeyboard/Services/FlowSessionManager`、`FlowPictureInPictureController`、`FlowTerminationCoordinator`、`FlowAnalyticsOperationRegistry`、`FlowASRPostProcessor`、`FlowDiagnostics`、`OSGKeyboardExt/Services/KeyboardFlowCoordinator`、`OSGKeyboardShared/Services/FlowSessionBridge*` | 已编,Mac 仅借用 `FlowSessionKeys.cloudASRWaitTimeout` 一个常量(`MacDictationViewModel.swift:498`) | **架构性差异**——Mac 单进程、单用户、单次录制;不需要也不该硬塞 | +| **自定义键盘(Rime/拼音/英文)** | 整个 `OSGKeyboardExt/Typing/` + `OSGKeyboardShared/Typing/**` + `LibrimeEngine` + `RimeResourceInstaller` + `EnglishSuggestionEngine` + `Pinyin*` + `Rime*` | **Mac target 完全排除** `Typing/**`(`project.yml:585`) | **架构性差异**——Mac 不是键盘扩展;librime 是 iOS C/Objective-C++ 框架,Mac 结构性不兼容 | +| **后台任务** | iOS `UIBackgroundModes: audio` + `BGTaskSchedulerPermittedIdentifiers: [com.osgkeyboard.ios.analytics-sync]` | Mac Info.plist **无** `BG*` keys | Mac app 不能后台运行;iOS 的 analytics 同步、Flow PiP 都没 Mac 对应 | +| **Settings 子页面** | `TypingInputSettingsView` / `AIAgentSkillsView` / `Account/*` / `HelpFeedbackView` / `ReleaseNotesSheet` / `AppGroupErrorView` / `NewContactSheet` / `KeyboardPreviewSheet` / 各种 DEBUG demo | 无 Mac 对应 | 见上表 | +| **Universal Links 启动 handoff** | iOS `applinks:osglab.com` + `AppURLHandler` 保留 `sourceApplication` | Mac entitlement 共享同 `com.apple.developer.associated-domains` 但**无** Mac-side 解析 | Mac 不能从网页/邮件 deep-link 进 Onboarding 或 Skill | + +### 3.2 部分缺失 + +| 缺失 | iOS | macOS | 差异点 | +|---|---|---|---| +| **Onboarding 深度** | 7 步 + OOBE 4-feature 实践(用 `OOBEClientInfrastructure` 拿服务器体验金) | 6 步(welcome / mic / accessibility / engine / cloud\|local) | 无 Sign in with Apple、无 OOBE 实践 | +| **设置覆盖面** | 7 个子面板 + Account + AI Agent + Clipboard + Typing Input | 5 个段落(support / general / recognition / ASR / local ASR / polish / input / legal) | iOS 的 AI Agent / Clipboard / Typing 在 Mac 没对应;Mac 的 input 段含热键 + Accessibility 状态(iOS 没有) | +| **AI Hint / Shortcut 链接** | 提示页 + 3 bundled `.shortcut` | 仅 0(Mac 装包不含 `.shortcut`) | 整套 AI mode 体验 Mac 无 | +| **Provider 切换粒度** | iOS 通过 iOS `EnginePickerSection` + `ProviderPickerSection` 在 Settings 内 + 主页快捷 | Mac 整段 `recognitionSection` + `asrProviderSection` + `polishProviderSection` 拆开 | iOS 较紧,Mac 较松 | + +### 3.3 Mac 自身落后 iOS 之处("应该补"清单) + +按"代码复用难度 + 用户价值"排序: + +| 优先级 | 功能 | 现状 | 工作量 | 建议 | +|---|---|---|---|---| +| **P0** | **剪贴板历史 + AI 技能面板(Mac UI)** | 共享代码全到位,零 Mac UI | **小**——`MacClipboardHistoryView` + `MacAIClipboardSkillView`(仿 `MacHistoryView`)+ 改 `MacSettingsView` 加一个 Clipboard 段 | 立刻做。代码复用 100%,价值高 | +| **P0** | **CLM 设置页(Mac UI)** | 后台在跑(`OSGKeyboardMacApp:117`),无 UI | **小**——`MacCLMSettingsView`,仿 `MacLocalASRModelSettingsView` 模板 | 立刻做。让用户能编辑 phrase 库 | +| **P1** | **OSG 账户 + Sign in with Apple + 积分购买** | 共享代码编进 Mac,零 UI;Mac entitlement 缺 `com.apple.developer.applesignin` + 无 `SKIncludeConsumableInAppPurchaseHistory` | **中**——加 entitlement + 改 `MacICloudSyncBootstrap` 已涵盖大部分,加 `MacAccountCenterView`(仿 `AccountCenterView` 但精简)+ 改 Settings | 一周内可交付 | +| **P1** | **OSG credits 消耗型 IAP(500tks/1500tks/3000tks)** | 共享 `TipPurchaseManager` 已装;iOS 走 `AccountCreditPurchaseManager` | **中**——`MacAccountCenterView` 出来后一气呵成 | 同上 | +| **P2** | **一方分析(Mac)** | 共享 `AnalyticsClient` 等全编,零调用 | **中-大**——`MacAnalyticsHostService` + 隐私 / opt-in + 后台任务(Mac 上走 `NSProcessInfo.thermalState` 节流 + 用户同意时 `NSTask` 跑)| 半年窗口内 | +| **P2** | **AI 提示轮播 / 上下文技能(Mac)** | 共享代码全到位 | **中**——加 `MacAIHintPool` 屏 + `MacAIQuestionService` 入口 + Shortcuts bundle 装载 | 与 P0 剪贴板可联动 | +| **P3** | **助手指令集成** | 共享代码 + iOS bundled `.shortcut` | **中**——Mac 装 `.shortcut` + `AIAgentShortcutInstaller` 调起(`shortcuts://` URL 仍可用)| 与 P2 AI 提示联动 | +| **P3** | **最后输入编辑** | 共享代码全到位 | **大**——需要 Mac 端保留"刚才插入的引用"(`EditableInputReference`),但 Mac 走 Accessibility ⌘V 注入,**没有 `EditableInputReference` 的来源**——需要新协议 | 架构问题,先做 PoC | +| **P3** | **后台同步(Analytics 任务)** | 无 | **中**——`NSTask`/`SMAppService` LaunchAgent 拉 analytics;与 P2 联动 | | + +### 3.4 Mac 形态上"不应该补"的功能 + +| 功能 | 原因 | +|---|---| +| **Flow 跨进程会话** | Mac 单进程、单用户、单次录制——Flow 设计为键盘扩展 ↔ 宿主 App 的两进程 mailbox。强行移植会引入不必要的 IPC 开销。**Mac 用 `MacDictationPipeline` + `MacDictationViewModel` + `MacDictationOverlayController` 三件套已足够** | +| **PiP keep-alive** | macOS 无系统 PiP。`MacDictationOverlayController` 的 NSPanel 已占位 | +| **Rime / 拼音 / 英文 autocorrect** | Mac 不是键盘扩展,没输入面。`LibrimeEngine` 是 iOS C/Objective-C++ xcframework,**Mac 结构性不兼容**。即便 Mac 装键盘扩展(`Designed for iPad` 那种),librime 也需要重编译 | +| **`UIBackgroundModes: audio`** | Mac 没用——按住说话时 NSPanel 是 non-activating,不需要 audio session 后台保持 | +| **`UIOpenURLContext.options.sourceApplication`** | iOS 26 only API;Mac 走 `NSWorkspace.didActivateApplicationNotification` 已解决前台 App 跟踪 | +| **`AVAudioApplication.requestRecordPermission`** | iOS 17+ only;Mac 走 `AVCaptureDevice.requestAccess(for: .audio)` | +| **App Group 跨进程 mailbox** | Mac 单进程不需要。但 `AppGroup` + iCloud KVS 是用户态多设备同步用的,**保留** | +| **iOS 风格的 idle 屏** | Mac 主窗口即 dashboard,不需要 idle 屏的"AI hint 轮播";但 `MacAIHintPool` 屏可以放在 Dashboard 顶部,与 P2 联动 | +| **`LSApplicationQueriesSchemes`** | iOS only,Mac 无 `canOpenURL` 限制 | +| **`SKIncludeConsumableInAppPurchaseHistory` / Mac storekit 文件** | Mac App Store 与 iOS App Store 独立 SKU;目前 Mac 装包是 Developer ID 渠道,**不进 Mac App Store**,所以 storekit 不需要 | + +--- + +## 4. 适合在 Mac 上做开发和移植的功能 + +### 4.1 应该现在做的(P0) + +#### ① 剪贴板历史 + AI 技能面板 + +**为什么 Mac 适合**:共享代码 100% 到位(`ClipboardHistoryStore` / `ClipboardSemanticAnalyzer` / `ClipboardSkillSemanticRanker` / `AIClipboardSkill` / `AIClipboardPrompt`),iOS 8 个 UI 调试稳定,只需套 Mac 风格。 + +**具体工作**: +- 新增 `OSGKeyboardMac/Views/MacClipboardHistoryView.swift`(仿 `MacHistoryView`) +- 新增 `OSGKeyboardMac/Views/MacAIClipboardSkillView.swift`(仿 `AIAgentSkillsView`,精简为 Read/Edit/Disable) +- `MacSettingsView.swift` 加 `clipboardSection`(含 toggle 启用历史 / toggle 启用技能候选条 / "Manage Skills" 链接 / "Open History" 链接) +- `MacDictationViewModel` 订阅 `ClipboardHistoryStore.entries`(`@Published var clipboardEntries`) +- 复用 `ClipboardCaptureCoordinator` 不可(ext-only)——需要 Mac 端 `MacClipboardMonitor`(`NSPasteboard.general.changeCount` 轮询 + sanitize) + +**测试**:`MacClipboardMonitorTests`(用 in-memory pasteboard 模拟)/ `MacClipboardHistoryViewModelTests` + +**工作量**:1-2 周 + +#### ② CLM 设置页 + +**为什么 Mac 适合**:Mac 后台已经在跑 CLM(`OSGKeyboardMacApp:117`),只是用户管不到。 + +**具体工作**: +- 新增 `OSGKeyboardMac/Views/MacCLMSettingsView.swift`(仿 `MacLocalASRModelSettingsView`) +- 复用 `CustomLanguageModelManager.shared.state`(`@Published idle/preparing/ready/failed`) +- 复用 `OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin`(已在 Mac 装包) +- 复用 `PersonalDictionary` 共享存储(用户输入的 phrase 库 + iCloud 同步) +- 提供 phrase 列表 / 编辑 / 触发"重编译"按钮 + +**测试**:`MacCLMSettingsViewModelTests`(验 `state` 状态机) + +**工作量**:1 周 + +### 4.2 应该中期做的(P1) + +#### ③ OSG 账户 + Sign in with Apple + 积分购买 + +**为什么 Mac 适合**:共享代码编进 Mac 都没报错,缺的是 entitlement + UI。 + +**先决条件**: +- `OSGKeyboardMac.entitlements` 加 `com.apple.developer.applesignin: [Default]` +- `OSGKeyboardMac/Info.plist` 加 `SKIncludeConsumableInAppPurchaseHistory: true` +- (选做)把 `OSGKeyboard.storekit` 内容 mirror 到 `OSGKeyboardMac.storekit` 或共享 + +**具体工作**: +- 加 `MacAccountCenterView`(仿 iOS `AccountCenterView` 但精简) +- 复用 `LiveAccountServices` / `AccountSessionCoordinator`(`@MainActor` state machine) +- 复用 `AccountCreditPurchaseManager`(StoreKit 2 product list → 服务器校验) +- Settings → 加 "Account" 段,含 sign-in / 积分余额 / 购买历史 / 登出 / 删号 + +**测试**:`MacAccountSessionCoordinatorTests`(in-memory `AccountAPIClient` fake) + +**工作量**:2-3 周 + +#### ④ Mac App Attest / DeviceCheck + +**为什么 Mac 适合**:iOS attestation 后端已经在用,Mac 端可以通过 `DCAppAttestService`(macOS 13+,需要 Mac Catalyst 但当前 `SUPPORTS_MACCATALYST: NO`)或者改用 DeviceCheck `DCDevice.generateToken`(macOS 13+ available without Catalyst)。 + +**先决条件**: +- `OSGKeyboardMac.entitlements` 加 `com.apple.developer.devicecheck.appattest-environment: $(APP_ATTEST_ENVIRONMENT)` +- 若走 App Attest,需要 `SUPPORTS_MACCATALYST: YES`(改大改)或改 DeviceCheck-only + +**具体工作**: +- `MacDeviceIntegrityCoordinator`(仿 iOS,但用 `DCDevice`) +- `AccountAPIClient` 复用,调 `/v1/integrity/attest` + +**工作量**:1-2 周(仅 Mac);+ Catalyst 决策时间 + +### 4.3 应该长期做的(P2) + +#### ⑤ 一方分析(Mac) + +**为什么 Mac 适合**:Mac 用户行为和 iOS 同样有价值(知道用户用不用 MLX 模式、热键触发频率、词条覆盖、润色样式流行度)。 + +**先决条件**: +- 共享 `AnalyticsClient` / `AnalyticsRepository` 已经完备 +- Mac 上加 `MacAnalyticsHostService`(仿 iOS):监听 `viewModel.$isRecording/$isPreparingToRecord/$isProcessing` 推送事件 +- 隐私 / opt-in:在 onboarding 末加一步 + +**挑战**: +- Mac 后台无 `BGTaskScheduler`——改用 `NSProcessInfo.thermalState` + `ProcessInfo.isLowPowerModeEnabled` 节流,**或** `SMAppService` 拉个 LaunchAgent +- `AnalyticsUploadCoordinator` 的移动策略(threshold 20 / 60s flush)要 Mac 化 + +**工作量**:3-4 周 + +#### ⑥ AI 提示轮播 / 上下文技能 + +**为什么 Mac 适合**:Mac dashboard 缺内容——除了使用统计,闲置时可推"想一句"灵感卡。 + +**具体工作**: +- `MacAIHintPool` 嵌在 `DashboardView` 顶部(仿 iOS `AIKeyboardView` 的 carousel 区域) +- 复用 `AIHintStore` / `AIHintPool` / `AIHintKeywordExtractor` / `AIHintLocalCatalog` +- 上下文技能入口放 `MacContentView`(popover 内 record 按钮旁的下拉) + +**工作量**:2-3 周 + +#### ⑦ 助手指令集成 + +**为什么 Mac 适合**:Mac 上有 Shortcuts.app(更成熟),bundled `.shortcut` 装入 Mac App bundle 即可。 + +**具体工作**: +- 装 `OSGKeyboard/Resources/Shortcuts/*.shortcut` 进 Mac bundle +- 复用 `AIAgentShortcutInstaller` / `AIAgentShortcutRun` +- popover / dashboard 加技能快捷入口 + +**工作量**:1-2 周 + +### 4.4 不应该在 Mac 做的(但代码可以清理) + +| 共享代码 | 为什么不该在 Mac 跑 | 处置 | +|---|---|---| +| `OSGKeyboardHostSupport/Features/Account/*`(Mac 已编) | App Attest 路径 iOS 专属;Mac 没 entitlement | 等 ④ 决策后从 Mac target 排除或接 UI | +| `OSGKeyboardShared/Features/Analytics/*`(Mac 已编) | 无 Mac 端调用 | 等 ⑤ 决策后从 Mac target 排除或接服务 | +| `OSGKeyboardShared/Features/ManagedGateway/*`(Mac 已编) | 是账户 grant 体系,与 ③ 绑定 | 等 ③ 决策后处理 | +| `OSGKeyboardShared/Services/AIClipboard*`(Mac 已编) | 与 ① 绑定 | ① 实现后从"无调用"变"有调用" | +| `OSGKeyboardShared/Services/AIHint*`(Mac 已编) | 与 ⑥ 绑定 | 同上 | +| `OSGKeyboardShared/Services/AIUserSkill*` / `AIAgentSkill*`(Mac 已编) | 与 ③⑦ 绑定 | 同上 | +| `OSGKeyboardShared/Services/Flow*`(Mac 已编) | Mac 架构不兼容 | **立刻**从 Mac target 排除(白付编译器开销) | +| `OSGKeyboardShared/Services/KeyboardState.swift`(Mac 已排除)| 已经是 Mac 排除 ✓ | 无 | +| `OSGKeyboardShared/Services/EditTransactionStore.swift` 等 | 与"最后输入编辑"绑定 | 见 P3 | + +### 4.5 立即可清的"白编译"清单 + +``` +OSGKeyboardShared/Services/FlowSessionBridge.swift + 所有 +* 文件 +OSGKeyboardShared/Services/FlowSessionKeys.swift +OSGKeyboardShared/Services/FlowSessionDarwin.swift +OSGKeyboardShared/Services/FlowSessionPolicy.swift +OSGKeyboardShared/Services/FlowStartTransactionPolicy.swift +OSGKeyboardShared/Services/FlowKeyboardPolicies.swift +OSGKeyboardShared/Services/FlowHandoffPolicy.swift +OSGKeyboardShared/Services/UtteranceStreamChunker.swift +OSGKeyboardShared/Services/UtteranceBatchFallbackPolicy.swift +OSGKeyboardShared/Services/UtteranceTranscriptGuard.swift +OSGKeyboardShared/Services/UtteranceTranscriptStitcher.swift +OSGKeyboardShared/Services/TranscriptOverlapUtilities.swift +OSGKeyboardShared/Services/ProgressiveDictationTranscriptAccumulator.swift # 已排除 +OSGKeyboardShared/Services/EditTransactionStore.swift +OSGKeyboardShared/Services/EditUsageMetricsStore.swift +OSGKeyboardShared/Services/EditLastInputPromptComposer.swift +OSGKeyboardShared/Services/EditOutputValidator.swift +OSGKeyboardShared/Models/Flow*.swift (全部) +OSGKeyboardShared/Models/EditableInputReference.swift +OSGKeyboardShared/Models/EditSessionState.swift +OSGKeyboardShared/Models/FlowUtterance*.swift +OSGKeyboardShared/Models/FlowInactivityDuration.swift +OSGKeyboardShared/Utilities/FlowCaptureTailDrain.swift +OSGKeyboardShared/Utilities/FlowUtteranceEndCoordinator.swift +OSGKeyboardShared/Utilities/FlowUtterancePCMStore.swift +OSGKeyboardShared/Utilities/FlowTrace.swift +OSGKeyboardShared/Utilities/FlowPipelineDiagnostics.swift +OSGKeyboardShared/Utilities/HostMemoryBudget.swift +OSGKeyboardShared/Utilities/KeyboardExtensionMemoryTelemetry.swift # 已排除 +OSGKeyboardShared/Features/Analytics/ # 全部 12+ 文件 +OSGKeyboardShared/Features/ManagedGateway/ # 全部 8+ 文件 +OSGKeyboardHostSupport/Features/Account/ # 全部 +OSGKeyboardHostSupport/Services/CloudASR/AlibabaVocabularySync.swift # iOS-only API +OSGKeyboardHostSupport/Services/ASRChunkTranscribing.swift # 需检查 +``` + +(**建议**):在 `project.yml:558-602` 给 Mac target 的 source list 加 `excludes:`,避免编译这些文件后被链接器裁掉造成的 dead-code 体积。 + +--- + +## 5. iOS 端需要"修一下"或注意的地方 + +报告主体是 macOS 差距,但 iOS 端顺手列出 5 个明显可改进点(不修不影响功能,但提升质量): + +1. **`OSGKeyboardHostSupport/Features/Account/*` 整块**虽然不通过 Mac UI 暴露,但通过 source include 编进 Mac 二进制——长期应从 Mac target 排除或加 `MacAccountServices` 实际使用 +2. **`OSGKeyboardShared/Features/Analytics/*`** 同上——Mac 编译进二进制但无任何调用 +3. **`OSGKeyboardShared/Features/ManagedGateway/*`** 同上 +4. **`project.yml:570-602`** 的 Mac target source list 很长且依赖手写排除,**建议**拆出 `OSGKeyboardMacExcludes.yml` 维护 +5. **`OSGKeyboardShared/Typing/EnglishSystemLexicon.swift:11`** 是**唯一**有 `import UIKit` 的 Shared 文件——是 iOS ext 独享的 QuickType 数据源。考虑改名/移动到 `OSGKeyboardExt/` 减面 +6. **iOS `FlowSessionManager` ~3500 LOC** 是单一 god object;建议把 `FlowPictureInPictureController` / `FlowTerminationCoordinator` / `FlowAnalyticsOperationRegistry` 三个生命周期拥有者抽离 +7. **iOS `appDelegate` 反弹 `.onOpenURL`** (`AppURLHandler.swift`) 注释里写 `UIOpenURLContext.options.sourceApplication` iOS 26 only——这个 API 在 iOS 26 还在吗?值得 verify(影响 deep-link host-return 白名单) +8. **`KeyboardViewController` `didReceiveMemoryWarning` (`:390-404`)** 强制回 voice surface——typing 引擎是最大内存消费者,但每次收到警告就退出 typing 体验略激进;考虑加 cooldown + +--- + +## 6. 实施建议 + +### 6.1 短期(未来 1-2 周) + +- **任务 A:从 Mac target 排除 `Flow*` / `Edit*` / `Analytics*` / `ManagedGateway*` / `Account*`(HostSupport)所有共享代码** + - 修改 `project.yml:570-602`,加 `excludes:` 列表 + - 验证 Mac target 仍能 build & test pass + - 节省 Mac 二进制 ~2-4 MB + 减少编译时间 ~10-15s +- **任务 B:Mac 剪贴板历史 + AI 技能面板**(P0 ①) +- **任务 C:Mac CLM 设置页**(P0 ②) + +### 6.2 中期(1-2 月) + +- **任务 D:OSG 账户 + Sign in with Apple + 积分**(P1 ③) +- **任务 E:App Attest 决策 + 实施**(P1 ④) + +### 6.3 长期(3-6 月) + +- **任务 F:Mac 一方分析**(P2 ⑤) +- **任务 G:AI 提示轮播 / 上下文技能**(P2 ⑥) +- **任务 H:助手指令集成**(P2 ⑦) + +### 6.4 决策项 + +| 决策 | 选项 | 影响 | +|---|---|---| +| **App Attest 路径** | (a) Catalyst + `DCAppAttestService`(macOS 13+)
(b) 仅 `DCDevice.generateToken`(无 attestation)
(c) 不做 | (a) 需开 `SUPPORTS_MACCATALYST: YES` 改大改;
(b) 安全性较弱但改动小;
(c) Mac 不在 attestation 内 | +| **Mac 走 Mac App Store 还是 Developer ID** | (a) 维持 Developer ID(现状)
(b) 走 Mac App Store | (a) 维持沙盒关闭,Accessibility 自由;
(b) 需重写热键 + 注入方案(沙盒内 Accessibility 拿不到) | +| **是否上 Rime 替代方案** | (a) 不做(Mac 不是键盘)
(b) 内部输入法,绕过 librime | (b) 工作量极大,且与产品形态不符 | +| **Mac 是否支持 iOS 风格的"on-device + cloud hybrid"** | (a) 仅 engine-mode toggle(现状)
(b) 支持每句 hybrid(local first,失败 fallback cloud) | (b) 需要重新设计 MLX streaming 端 | + +--- + +## 7. 关键引用 + +| 主题 | 文件 | 行号 | +|---|---|---| +| iOS 宿主入口 | `OSGKeyboard/OSGKeyboardApp.swift` | 1 | +| iOS Flow 单一拥有者 | `OSGKeyboard/Services/FlowSessionManager.swift` | 1 (~3500 LOC) | +| iOS 键盘入口 | `OSGKeyboardExt/KeyboardViewController.swift` | 33-38 | +| iOS 键盘生命周期 | `OSGKeyboardExt/KeyboardViewController.swift` | 148-655 | +| iOS 共享 ASR 协议 | `OSGKeyboardShared/Services/PolishingService.swift` | 34-105 | +| iOS HostSupport ASR 工厂 | `OSGKeyboardHostSupport/Services/ASRService.swift` | 1 | +| iOS HostSupport 云 ASR 工厂 | `OSGKeyboardHostSupport/Services/CloudASR/CloudASRClientFactory` | 44 | +| Mac 入口 | `OSGKeyboardMac/OSGKeyboardMacApp.swift` | 1 | +| Mac MLX 流式 provider | `OSGKeyboardMac/MacMLXStreamingASRProvider.swift` | 1 | +| Mac MLX 流式 session | `OSGKeyboardMac/MacMLXStreamingSession.swift` | 1 | +| Mac MLX 实时捕获编排 | `OSGKeyboardMac/MacMLXLiveCapture.swift` | 1 | +| Mac 热键 | `OSGKeyboardMac/MacHotkeyService.swift` | 1 | +| Mac 文本注入 | `OSGKeyboardMac/MacTextInsertionService.swift` | 1 | +| Mac 音频 | `OSGKeyboardMac/MacAudioRecorder.swift` | 1 | +| Mac 字幕过滤器 | `OSGKeyboardMac/MacHallucinationFilter.swift` | 1 | +| Mac 字典流水线 | `OSGKeyboardMac/MacDictationPipeline.swift` | 254 | +| Mac 模型目录 | `OSGKeyboard/Resources/LocalASR/local-asr-catalog.json` | 1-111 | +| Mac target 定义 | `project.yml` | 543-674 | +| Mac target 共享 source 排除 | `project.yml` | 558-602 | +| Mac entitlements | `OSGKeyboardMac/OSGKeyboardMac.entitlements` | 1 | + +--- + +## 8. 附录:版本与里程碑 + +| 版本 | 日期 | 关键 Mac 相关变更 | +|---|---|---| +| 2.0.1 (build 90) | 2026-08-21 | (无 Mac-specific changelog 条目) | +| 2.0.0 (build 85) | 2026-08-19 | "Optional OSG account"(iOS only,但相关 HostSupport 代码编进 Mac)| +| 1.8.0 (build 72) | 2026-08-14 | "Mac Styles / Settings follow the same pairing"(设计系统共用)+ "Mac model downloads"(**MLX 模型下装载入**)| +| Pre-1.8.0 | — | Sherpa → MLX 迁移(`MacOnboardingView.swift:7-8` 注释)| + +--- + +**报告完。** +下次更新窗口:2026-09-XX 复审 P0 ① ② 实施情况 + 决策项 6.4 落地。 diff --git a/project.yml b/project.yml index d0f197c..2544b2d 100644 --- a/project.yml +++ b/project.yml @@ -48,8 +48,8 @@ settings: ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES STRING_CATALOG_GENERATE_SYMBOLS: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 - MARKETING_VERSION: "2.0.1" - CURRENT_PROJECT_VERSION: "89" + MARKETING_VERSION: "2.0.3" + CURRENT_PROJECT_VERSION: "94" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target @@ -484,10 +484,10 @@ targets: TARGETED_DEVICE_FAMILY: "1,2" # ========================================================= - # 实机 UI 测试 + # UI 测试:确定性宿主在模拟器运行,PiP/真实键盘链路保留实机验收 # ========================================================= - # XCUITest 是 Apple 唯一开放的物理设备触摸注入通道,模拟器无法复现 - # PiP 语音会话(isPictureInPictureSupported 恒为 false)。 + # XCUITest 是 Apple 唯一开放的物理设备触摸注入通道;模拟器可运行 + # 内嵌宿主回归,但无法复现 PiP(isPictureInPictureSupported 恒为 false)。 OSGKeyboardUITests: type: bundle.ui-testing platform: iOS