checkpoint before checking out main

This commit is contained in:
Rocky
2026-08-25 13:42:00 +08:00
parent 57844ce615
commit dccafe6f9e
105 changed files with 4302 additions and 1323 deletions
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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 |
|-------|------|------|
+26
View File
@@ -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 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。
+1 -1
View File
@@ -11,7 +11,7 @@
"image-name" : "OSGKeyboard Icon2.png",
"name" : "OSGKeyboard Icon2",
"position" : {
"scale" : 0.505,
"scale" : 0.508,
"translation-in-points" : [
8,
6
+68 -62
View File
@@ -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)
@@ -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) {
@@ -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
}
@@ -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()
@@ -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 {
@@ -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<Bool> {
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)
}
}
}
@@ -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
}
}
}
@@ -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,
@@ -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:
@@ -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<Header: View>: 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<Header: View>: 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<Header: View>: 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() }
)
}
}
@@ -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<Float>] = [
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<Float>] {
// 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)
}
@@ -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)
)
}
}
@@ -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.
@@ -37,8 +37,5 @@ struct WideCard<Content: View>: View {
content()
.padding(padding)
.background(palette.surface, in: shape)
.overlay(
shape.stroke(palette.divider, lineWidth: 0.5)
)
}
}
-1
View File
@@ -31,6 +31,5 @@ struct HelpFeedbackView: View {
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.link.support")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
+1 -2
View File
@@ -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)
+246 -201
View File
@@ -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<Content: View>(
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<Content: View>(@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
}
@@ -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 {
@@ -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"))
+6
View File
@@ -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")
+10 -6
View File
@@ -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
@@ -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
@@ -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)
)
}
}
-4
View File
@@ -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)
@@ -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()
}
}
+23 -35
View File
@@ -22,7 +22,7 @@ struct PersonalDictionaryView: View {
@State private var generatingAliasEntryIDs: Set<UUID> = []
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)
}
}
+9 -23
View File
@@ -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") {
@@ -18,7 +18,6 @@ struct PrivacyPolicyView: View {
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.privacy.policy")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
private var privacyScrollAnchor: String? {
+31 -37
View File
@@ -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<String> {
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)
}
}
+28 -21
View File
@@ -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)
+22 -32
View File
@@ -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<String> {
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.
@@ -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<String> {
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)
+32 -40
View File
@@ -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()
}
+91 -276
View File
@@ -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 {
+14 -9
View File
@@ -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<String> {
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")
@@ -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))
+14 -10
View File
@@ -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" = "Youve 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…";
+14 -10
View File
@@ -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" = "正在登录…";
+114 -27
View File
@@ -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
@@ -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
)
@@ -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 {
@@ -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
)
}
}
+56 -14
View File
@@ -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)
+5 -3
View File
@@ -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,
+4 -4
View File
@@ -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";
@@ -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" = "总结网页";
@@ -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
}
}
+142 -1
View File
@@ -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)
@@ -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"]
@@ -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[..<startIndex])
}
private var leadingEmptyCellCount: Int {
guard let firstDate = points.first?.date else { return 0 }
let weekday = displayCalendar.component(.weekday, from: firstDate)
return (weekday - displayCalendar.firstWeekday + 7) % 7
}
private var calendarCells: [UsageStatisticsStore.DailyUsagePoint?] {
Array(repeating: nil, count: leadingEmptyCellCount) + points.map(Optional.some)
}
private var cellDiameter: CGFloat {
compact ? 28 : 32
}
public var body: some View {
Group {
if embedsInCard {
UsageSurfaceCard(padding: Spacing.md) {
calendarContent
}
} else {
calendarContent
}
}
}
private var calendarContent: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
header
weekdayHeader
dayGrid
}
.frame(maxWidth: .infinity, alignment: .topLeading)
}
private var header: some View {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 2) {
Text(monthTitle.uppercased())
.font(TypeStyle.caption2)
.tracking(0.6)
.foregroundStyle(palette.textTertiary)
Text(SharedL10n.string("stat.monthChart.caption", language: language))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
Spacer(minLength: Spacing.sm)
Text(UsageStatisticsStore.formatCount(total, language: language))
.font(TypeStyle.title2)
.foregroundStyle(palette.accent)
.lineLimit(1)
.minimumScaleFactor(0.7)
.contentTransition(.numericText())
.animation(Motion.soft, value: total)
}
}
private var weekdayHeader: some View {
LazyVGrid(columns: columns, spacing: 0) {
ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in
Text(symbol)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity)
}
}
}
private var dayGrid: some View {
LazyVGrid(columns: columns, spacing: compact ? 3 : 5) {
ForEach(Array(calendarCells.enumerated()), id: \.offset) { _, point in
if let point {
dayCell(point)
} else {
Color.clear
.frame(width: cellDiameter, height: cellDiameter)
}
}
}
}
private var columns: [GridItem] {
Array(
repeating: GridItem(.flexible(minimum: cellDiameter), spacing: 4),
count: 7
)
}
private func dayCell(_ point: UsageStatisticsStore.DailyUsagePoint) -> 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()
}
}
@@ -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<Header: View>: View {
@Environment(\.themePalette) private var palette
@@ -33,9 +39,12 @@ public struct UsageStatsCluster<Header: View>: 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<Header: View>: 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<Header: View>: 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<Header: View>: 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<Header: View>: 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() }
)
}
@@ -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
}
}
+2 -6
View File
@@ -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()
+1 -18
View File
@@ -133,7 +133,7 @@ private struct MacGlassSurface<S: Shape>: 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<Content: View>: 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<Content: View>: View {
content()
.padding(padding)
.macGlassSurface(in: shape, fillOpacity: 1)
.overlay(
shape
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
+12 -21
View File
@@ -14,8 +14,6 @@ struct MacDictionaryView: View {
@State private var showEntryEditor = false
@State private var generatingAliasEntryIDs: Set<UUID> = []
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)
}
}
}
@@ -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)
}
+2 -7
View File
@@ -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)
-10
View File
@@ -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).
@@ -13,7 +13,7 @@ public struct CardPageContent<Content: View>: 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
}
+8 -8
View File
@@ -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)
}
}
@@ -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<Content: View>: View {
.padding(padding)
.background(palette.surface, in: shape)
.clipShape(shape)
.overlay(
shape.stroke(palette.divider, lineWidth: 0.5)
)
}
}
@@ -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
@@ -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
@@ -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
)
+1 -13
View File
@@ -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
@@ -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)
}
@@ -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<String> = [
replyID,
declineInvitationID,
empathyReplyID,
acceptInvitationID,
callPhoneID,
createContactID,
clarifyRequestID
]
public static let extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos"
public static let extractTodosResourceName = "OSGExtractTodos"
+37 -7
View File
@@ -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<Result>(
_ 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 }
@@ -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
}
@@ -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.
@@ -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
}
}
@@ -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]
@@ -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"
@@ -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
@@ -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
}
@@ -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<String>,
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<String> = [
"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<Character> = ["-", ".", "+", "#", "·"]
private static let allowedSeparators: Set<Character> = ["-", "'", ".", "+", "#", "·"]
}
@@ -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()
}
}
@@ -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<Void, Never>?
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
}
}
}
+3 -2
View File
@@ -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";
@@ -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" = "聆听中";
+102 -7
View File
@@ -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)
@@ -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,
@@ -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(
@@ -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 {
@@ -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)
@@ -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?",
@@ -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 {
@@ -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))
}
}
@@ -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")
}
@@ -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)
}
}
@@ -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)")
@@ -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() {
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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 个技能放到键盘,支持长按拖动排序
- 技能安装数量不限;技能页可安装、卸载和排序,键盘按当前剪贴板内容动态展示相关技能
## 三种服务路径
+19 -6
View File
@@ -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
+4 -1
View File
@@ -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": {

Some files were not shown because too many files have changed in this diff Show More