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 run: ./Scripts/generate-xcodeproj.sh
- name: Run PR test preset - name: Run PR test preset
run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
- name: Run account and purchase UI regression - name: Run simulator-stable UI regressions
run: | run: |
set -euo pipefail set -euo pipefail
xcodebuild \ xcodebuild \
@@ -71,6 +71,8 @@ jobs:
-scheme OSGKeyboardUITests \ -scheme OSGKeyboardUITests \
-destination 'platform=iOS Simulator,name=iPhone 17' \ -destination 'platform=iOS Simulator,name=iPhone 17' \
-only-testing:OSGKeyboardUITests/AccountCenterUITests \ -only-testing:OSGKeyboardUITests/AccountCenterUITests \
-only-testing:OSGKeyboardUITests/AssistantKeyboardUITests \
-only-testing:OSGKeyboardUITests/EditPagerUITests \
-onlyUsePackageVersionsFromResolvedFile \ -onlyUsePackageVersionsFromResolvedFile \
test test
- name: Build Release - name: Build Release
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format ### 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 | | 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] ## [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 ### 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. / **电话号码剪贴板操作**:在本地识别复制内容中的唯一电话号码,并提供系统拨号或预填号码的新建联系人表单。 - **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 安全、限量提取公开网页正文后生成总结。 - **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 附加风格,并在保存前强制检查。 - **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 ### 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. / **聚焦剪贴板操作**:将外语剪贴板文本翻译为设备首选系统语言,兼容迁移并整合重复的回复、总结与澄清技能,区分网页总结,并避免为负面消息推荐俏皮回复。 - **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 说明,并将空状态精简为“暂无推荐词”。 - **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. / **技能目录布局**:将双列卡片改为可稳定滚动的已安装与未安装列表;点击列表项进入完整详情页,并统一管理安装、捷径配置与自定义技能编辑。 - **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. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。 - **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 ### 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 时可以重新体验四个页面,并避免把页面已完成冲突误报为弱网;同时精简各环节标题,加入语音朗读示例,并简化完成页。 - **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 思考策略,时效内容强制在线搜索,保留热词用量归因,并区分积分、服务商、超时与网关故障。 - **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", "image-name" : "OSGKeyboard Icon2.png",
"name" : "OSGKeyboard Icon2", "name" : "OSGKeyboard Icon2",
"position" : { "position" : {
"scale" : 0.505, "scale" : 0.508,
"translation-in-points" : [ "translation-in-points" : [
8, 8,
6 6
+68 -62
View File
@@ -9,6 +9,10 @@ import Foundation
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI import SwiftUI
private enum SkillRoute: Hashable {
case detail(String)
}
struct AIAgentSkillsView: View { struct AIAgentSkillsView: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@@ -20,20 +24,21 @@ struct AIAgentSkillsView: View {
@State private var pasteAccessNeedsRecovery = false @State private var pasteAccessNeedsRecovery = false
@State private var showPasteNoTextAlert = false @State private var showPasteNoTextAlert = false
@State private var showPasteAccessSuccess = false @State private var showPasteAccessSuccess = false
@State private var path = NavigationPath()
private var skillCardShape: RoundedRectangle { private var skillCardShape: RoundedRectangle {
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
} }
var body: some View { var body: some View {
NavigationStack { NavigationStack(path: $path) {
ScrollView { ScrollView {
CardPageContent(spacing: Spacing.xl) { CardPageContent {
if showsClipboardAccessGuide { if showsClipboardAccessGuide {
clipboardAccessGuide clipboardAccessGuide
.transition(.opacity.combined(with: .move(edge: .top))) .transition(.opacity.combined(with: .move(edge: .top)))
} }
installedSection installedSection
if !store.availableSkills.isEmpty { if !store.skillManagementAvailableSkills.isEmpty {
uninstalledSection uninstalledSection
} }
} }
@@ -42,6 +47,19 @@ struct AIAgentSkillsView: View {
.background(palette.background) .background(palette.background)
.navigationTitle("skills.title") .navigationTitle("skills.title")
.navigationBarTitleDisplayMode(.large) .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 { .toolbar {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
Button { Button {
@@ -53,6 +71,7 @@ struct AIAgentSkillsView: View {
} }
} }
} }
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
.sheet(item: $editingDraft) { draft in .sheet(item: $editingDraft) { draft in
SkillEditorSheet( SkillEditorSheet(
draft: draft, draft: draft,
@@ -94,11 +113,7 @@ struct AIAgentSkillsView: View {
private var clipboardAccessGuide: some View { private var clipboardAccessGuide: some View {
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(alignment: .top, spacing: Spacing.sm) { HStack(alignment: .top, spacing: Spacing.sm) {
Image(systemName: clipboardGuideIcon) skillIcon(systemImage: clipboardGuideIcon)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(clipboardGuideTint)
.frame(width: 36, height: 36)
.background(clipboardGuideTint.opacity(0.12), in: Circle())
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(clipboardGuideTitle) Text(clipboardGuideTitle)
@@ -126,7 +141,6 @@ struct AIAgentSkillsView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surface, in: skillCardShape) .background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.accessibilityIdentifier("skills.clipboard.guide") .accessibilityIdentifier("skills.clipboard.guide")
} }
@@ -138,7 +152,8 @@ struct AIAgentSkillsView: View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.monochrome)
.frame(width: 22) .frame(width: 22)
Text(titleKey) Text(titleKey)
.font(TypeStyle.body) .font(TypeStyle.body)
@@ -194,10 +209,6 @@ struct AIAgentSkillsView: View {
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard" return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
} }
private var clipboardGuideTint: Color {
pasteAccessNeedsRecovery ? palette.warning : palette.accent
}
private var clipboardGuideActionTitle: LocalizedStringKey { private var clipboardGuideActionTitle: LocalizedStringKey {
if !config.clipboardHistoryEnabled { if !config.clipboardHistoryEnabled {
return "skills.clipboard.guide.enableHistory" return "skills.clipboard.guide.enableHistory"
@@ -288,53 +299,36 @@ struct AIAgentSkillsView: View {
title: AppL10n.format( title: AppL10n.format(
"skills.installed.section", "skills.installed.section",
language: config.uiLanguage, language: config.uiLanguage,
store.enabledSkills.count store.skillManagementEnabledSkills.count
) )
) { ) {
if store.enabledSkills.isEmpty { if store.skillManagementEnabledSkills.isEmpty {
Text("skills.installed.empty") Text("skills.installed.empty")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} else { } else {
LazyVStack(spacing: 0) { LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(store.enabledSkills) { skill in ForEach(store.skillManagementEnabledSkills) { skill in
skillListItem(skill) skillListItem(skill)
} }
} }
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
} }
} }
} }
private var uninstalledSection: some View { private var uninstalledSection: some View {
CardSection("skills.uninstalled.section") { CardSection("skills.uninstalled.section") {
LazyVStack(spacing: 0) { LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(store.availableSkills) { skill in ForEach(store.skillManagementAvailableSkills) { skill in
skillListItem(skill) skillListItem(skill)
} }
} }
.background(palette.surface, in: skillCardShape)
.overlay(skillCardShape.stroke(palette.divider, lineWidth: 0.5))
.clipShape(skillCardShape)
} }
} }
private func skillListItem(_ skill: AIClipboardSkill) -> some View { private func skillListItem(_ skill: AIClipboardSkill) -> some View {
NavigationLink { NavigationLink(value: SkillRoute.detail(skill.id)) {
SkillDetailView(
store: store,
skill: skill,
onInstall: addOrWarn,
onConfirmInstall: confirmShortcutInstall,
onEdit: {
guard let user = store.userSkill(id: skill.id) else { return }
editingDraft = .from(user)
}
)
} label: {
skillListRow(skill) skillListRow(skill)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -349,14 +343,7 @@ struct AIAgentSkillsView: View {
private func skillListRow(_ skill: AIClipboardSkill) -> some View { private func skillListRow(_ skill: AIClipboardSkill) -> some View {
HStack(spacing: Spacing.md) { HStack(spacing: Spacing.md) {
Image(systemName: skill.systemImage) skillIcon(systemImage: 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
))
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(skillTitle(skill)) Text(skillTitle(skill))
@@ -378,16 +365,36 @@ struct AIAgentSkillsView: View {
.frame(maxWidth: .infinity, minHeight: 64, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 64, alignment: .leading)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs) .padding(.vertical, Spacing.xs)
.background(palette.surface) .background(palette.surface, in: skillCardShape)
.overlay(alignment: .bottom) { .clipShape(skillCardShape)
Rectangle()
.fill(palette.divider)
.frame(height: 0.5)
.padding(.leading, 38 + Spacing.md * 2)
}
.contentShape(Rectangle()) .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 { private func skillTitle(_ skill: AIClipboardSkill) -> String {
if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines), if let name = skill.customName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty { !name.isEmpty {
@@ -505,7 +512,6 @@ private struct SkillDetailView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
CardPageContent( CardPageContent(
spacing: Spacing.md,
topPadding: Spacing.md, topPadding: Spacing.md,
bottomPadding: Spacing.xl bottomPadding: Spacing.xl
) { ) {
@@ -555,9 +561,12 @@ private struct SkillDetailView: View {
HStack(alignment: .center, spacing: Spacing.md) { HStack(alignment: .center, spacing: Spacing.md) {
Image(systemName: skill.systemImage) Image(systemName: skill.systemImage)
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.frame(width: 36, height: 36) .frame(width: 38, height: 38)
.background(palette.accentMuted, in: Circle()) .background(
palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(headerTitle) Text(headerTitle)
.font(TypeStyle.title3) .font(TypeStyle.title3)
@@ -660,6 +669,7 @@ private struct SkillDetailView: View {
prominent: Bool, prominent: Bool,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
Button(action: action) { Button(action: action) {
Text(titleKey) Text(titleKey)
.font(TypeStyle.bodyEmph) .font(TypeStyle.bodyEmph)
@@ -667,11 +677,7 @@ private struct SkillDetailView: View {
.foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary) .foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
.background( .background(
prominent ? palette.accent : palette.surfaceElevated, prominent ? palette.accent : palette.surfaceElevated,
in: Capsule() in: shape
)
.overlay(
Capsule()
.stroke(prominent ? Color.clear : palette.dividerStrong, lineWidth: 0.5)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -25,6 +25,22 @@ struct AccountAppleAuthorizationButton: View {
@State private var rawNonce: String? @State private var rawNonce: String?
var body: some View { var body: some View {
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( SignInWithAppleButton(
purpose == .signIn ? .signIn : .continue, purpose == .signIn ? .signIn : .continue,
onRequest: prepareRequest, onRequest: prepareRequest,
@@ -44,6 +60,21 @@ struct AccountAppleAuthorizationButton: View {
? Text("account.signIn.hint") ? Text("account.signIn.hint")
: Text("account.delete.reauthenticateHint") : 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) { private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
@@ -12,13 +12,20 @@ import SwiftUI
@MainActor @MainActor
struct AccountCenterUITestHarness: View { struct AccountCenterUITestHarness: View {
@StateObject private var coordinator: AccountSessionCoordinator @StateObject private var coordinator: AccountSessionCoordinator
private let startsSignIn: Bool
init() { init() {
let startsSignIn = ProcessInfo.processInfo.arguments.contains(
"--account-signin-loading"
)
let service = AccountCenterUITestService( let service = AccountCenterUITestService(
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains( shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
"--account-snapshot-failure" "--account-snapshot-failure"
),
startsSignedOut: startsSignIn,
signInDelay: startsSignIn ? .seconds(5) : .zero
) )
) self.startsSignIn = startsSignIn
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID) let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
_coordinator = StateObject( _coordinator = StateObject(
wrappedValue: AccountSessionCoordinator( wrappedValue: AccountSessionCoordinator(
@@ -41,6 +48,15 @@ struct AccountCenterUITestHarness: View {
} }
.task { .task {
await coordinator.restoreIfNeeded() 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" displayName: "UI Test Account"
) )
private let shouldFailSnapshot: Bool 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.shouldFailSnapshot = shouldFailSnapshot
self.startsSignedOut = startsSignedOut
self.signInDelay = signInDelay
} }
func restoreSession() async throws -> AccountSession? { func restoreSession() async throws -> AccountSession? {
account startsSignedOut ? nil : account
} }
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession { func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
_ = payload _ = payload
if signInDelay > .zero {
try await Task.sleep(for: signInDelay)
}
return account return account
} }
@@ -26,7 +26,6 @@ struct AccountCenterView: View {
} }
.navigationTitle("account.title") .navigationTitle("account.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.task(id: coordinator.accountID) { .task(id: coordinator.accountID) {
guard let accountID = coordinator.accountID else { guard let accountID = coordinator.accountID else {
coordinator.creditPurchases.reset() coordinator.creditPurchases.reset()
@@ -116,11 +115,6 @@ struct AccountCenterView: View {
AccountAppleAuthorizationButton(purpose: .signIn) AccountAppleAuthorizationButton(purpose: .signIn)
.disabled(coordinator.operation != nil) .disabled(coordinator.operation != nil)
if coordinator.operation == .signingIn {
ProgressView("account.signIn.loading")
.tint(palette.accent)
}
if coordinator.pendingReferralCode != nil { if coordinator.pendingReferralCode != nil {
Label("account.referral.pendingAfterSignIn", systemImage: "link") Label("account.referral.pendingAfterSignIn", systemImage: "link")
.font(.footnote) .font(.footnote)
@@ -273,7 +267,6 @@ struct AccountCenterView: View {
} }
.padding(Spacing.lg) .padding(Spacing.lg)
.background(palette.surface, in: shape) .background(palette.surface, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
.accessibilityIdentifier("account.summary") .accessibilityIdentifier("account.summary")
} }
@@ -954,9 +947,12 @@ private struct AccountActionRow: View {
let showsProgress: Bool let showsProgress: Bool
var body: some View { var body: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.xs) {
Label(titleKey, systemImage: systemImage) Image(systemName: systemImage)
.font(.body) .font(.system(size: 16, weight: .regular))
.frame(width: 20)
Text(titleKey)
.font(TypeStyle.body)
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
if showsProgress { if showsProgress {
ProgressView() ProgressView()
@@ -8,6 +8,10 @@ import OSGKeyboardShared
import SwiftUI import SwiftUI
struct AccountPurchaseHistoryView: View { struct AccountPurchaseHistoryView: View {
private enum Layout {
static let purchaseRowMinHeight: CGFloat = 64
}
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@ObservedObject private var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@ObservedObject var manager: AccountCreditPurchaseManager @ObservedObject var manager: AccountCreditPurchaseManager
@@ -56,7 +60,6 @@ struct AccountPurchaseHistoryView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("account.purchaseHistory.title") .navigationTitle("account.purchaseHistory.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.task { .task {
await manager.loadPurchaseHistory(accountID: accountID) await manager.loadPurchaseHistory(accountID: accountID)
} }
@@ -69,6 +72,14 @@ struct AccountPurchaseHistoryView: View {
ForEach(records) { record in ForEach(records) { record in
purchaseRow(record) purchaseRow(record)
.listRowBackground(palette.surface) .listRowBackground(palette.surface)
.listRowInsets(
EdgeInsets(
top: 0,
leading: Spacing.md,
bottom: 0,
trailing: Spacing.md
)
)
.listRowSeparatorTint(palette.divider) .listRowSeparatorTint(palette.divider)
.task { .task {
guard record.id == records.last?.id else { return } guard record.id == records.last?.id else { return }
@@ -111,8 +122,8 @@ struct AccountPurchaseHistoryView: View {
private func purchaseRow( private func purchaseRow(
_ record: AccountCreditPurchaseRecord _ record: AccountCreditPurchaseRecord
) -> some View { ) -> some View {
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) { HStack(alignment: .center, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) { HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
Text("+") Text("+")
.font(TypeStyle.bodyEmph) .font(TypeStyle.bodyEmph)
@@ -121,27 +132,32 @@ struct AccountPurchaseHistoryView: View {
.font(TypeStyle.bodyEmph.monospacedDigit()) .font(TypeStyle.bodyEmph.monospacedDigit())
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("account.storekit.credits") Text("account.storekit.credits")
.font(TypeStyle.caption) .font(TypeStyle.body)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
Text(purchaseDateText(record.purchasedAt)) Text(purchaseDateText(record.purchasedAt))
.font(TypeStyle.caption) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
VStack(alignment: .trailing, spacing: Spacing.xxs) { VStack(alignment: .trailing, spacing: 2) {
Text(balanceAfterText(record.balanceAfter)) Text(balanceAfterText(record.balanceAfter))
.font(TypeStyle.caption.monospacedDigit()) .font(TypeStyle.body.monospacedDigit())
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
Text("account.purchaseHistory.status.credited") Text("account.purchaseHistory.status.credited")
.font(TypeStyle.caption.weight(.semibold)) .font(TypeStyle.caption2.weight(.semibold))
.foregroundStyle(palette.success) .foregroundStyle(palette.success)
} }
} }
.padding(.vertical, Spacing.xs) .frame(
maxWidth: .infinity,
minHeight: Layout.purchaseRowMinHeight,
alignment: .center
)
.accessibilityElement(children: .combine) .accessibilityElement(children: .combine)
.accessibilityIdentifier("account.purchaseHistory.row.\(record.transactionID)")
} }
private func purchaseDateText(_ date: Date) -> String { 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 redeemPendingReferralIfNeeded()
await refreshAccountData(force: true) await refreshAccountData(force: true)
} catch { } catch {
await sessionService.clearManagedGateway()
await onAccountSignedOut()
sessionPhase = .signedOut
operationErrorKey = errorMessageKey( operationErrorKey = errorMessageKey(
for: error, for: error,
fallback: "account.error.restore" 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? { func restoreSession() async throws -> AccountSession? {
guard let cachedSession = try await apiClient.currentSession() else { guard let cachedSession = try await apiClient.currentSession() else {
OSGDiag.log("session restore status=not-found", category: "account")
await invalidateManagedGatewaySession() await invalidateManagedGatewaySession()
return nil return nil
} }
OSGDiag.log("session restore status=keychain-found", category: "account")
let account: OSGAccount? let account: OSGAccount?
do { do {
account = try await apiClient.account() account = try await apiClient.account()
} catch let error as AccountAPIError { } catch let error as AccountAPIError {
switch error { switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse: case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
OSGDiag.log(
"session restore status=rejected "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
await invalidateManagedGatewaySession() await invalidateManagedGatewaySession()
return nil return nil
default: default:
OSGDiag.log(
"session restore status=cached-fallback "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
account = nil account = nil
} }
} catch { } catch {
OSGDiag.log(
"session restore status=cached-fallback "
+ "error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
account = nil account = nil
} }
let session = try await apiClient.currentSession() ?? cachedSession let session = try await apiClient.currentSession() ?? cachedSession
configuration.setManagedGatewayAccountSessionAvailable(true) configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant() await synchronizeManagedGrant()
OSGDiag.log(
"session restore status=restored accountValidated=\(account == nil ? 0 : 1)",
category: "account"
)
return uiSession( return uiSession(
session, session,
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0, createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
@@ -174,7 +174,20 @@ struct AssistantKeyboardUITestHarness: View {
AIKeyboardView.debugPreviewSkills = nil AIKeyboardView.debugPreviewSkills = nil
state.skillTipText = "Skill failed" state.skillTipText = "Skill failed"
case .skills: 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.undoAvailable = true
state.editAvailable = true state.editAvailable = true
case .search: case .search:
@@ -3,7 +3,7 @@
// //
// Observes usage + dictionary counts and feeds the shared // Observes usage + dictionary counts and feeds the shared
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header` // `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 OSGKeyboardHostSupport
import OSGKeyboardShared import OSGKeyboardShared
@@ -12,6 +12,9 @@ import SwiftUI
struct HomeUsageStatsSection<Header: View>: View { struct HomeUsageStatsSection<Header: View>: View {
let layout: UsageStatsClusterLayout let layout: UsageStatsClusterLayout
var compact: Bool = false var compact: Bool = false
var content: UsageStatsClusterContent = .all
var onOpenHistory: (() -> Void)?
var onOpenDictionary: (() -> Void)?
@ViewBuilder var header: () -> Header @ViewBuilder var header: () -> Header
@ObservedObject private var stats = UsageStatisticsStore.shared @ObservedObject private var stats = UsageStatisticsStore.shared
@@ -23,12 +26,15 @@ struct HomeUsageStatsSection<Header: View>: View {
UsageStatsCluster( UsageStatsCluster(
layout: layout, layout: layout,
language: config.uiLanguage, language: config.uiLanguage,
points: stats.last7Days, points: stats.currentMonth,
dictationCharacterCount: stats.dictationCharacterCount, dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds, dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount, translationCharacterCount: stats.translationCharacterCount,
dictionaryTermCount: dictionaryCount, dictionaryTermCount: dictionaryCount,
compact: compact, compact: compact,
content: content,
onOpenHistory: onOpenHistory,
onOpenDictionary: onOpenDictionary,
header: header header: header
) )
.onAppear(perform: refreshDictionaryCount) .onAppear(perform: refreshDictionaryCount)
@@ -49,8 +55,21 @@ struct HomeUsageStatsSection<Header: View>: View {
} }
extension HomeUsageStatsSection where Header == EmptyView { extension HomeUsageStatsSection where Header == EmptyView {
init(layout: UsageStatsClusterLayout, compact: Bool = false) { init(
self.init(layout: layout, compact: compact, header: { EmptyView() }) 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) .padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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 // TabBarVisibility.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Push NavigationStack tab // NavigationStack tab
// `hidesTabBarWhenPushed()` TabView // push / pop 使 Dock
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI import SwiftUI
// MARK: - Modifiers // 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 { extension View {
/// Marks this view as a pushed detail screen so the native tab bar hides. /// Keeps the native tab dock visible only at a NavigationStack root.
func hidesTabBarWhenPushed() -> some View { func navigationStackTabBarVisibility(isRoot: Bool) -> some View {
toolbar(.hidden, for: .tabBar) modifier(NavigationStackTabBarVisibilityModifier(isRoot: isRoot))
} }
/// Adds ordinary footer breathing room; native TabView owns tab-bar clearance. /// Adds ordinary footer breathing room; native TabView owns tab-bar clearance.
@@ -37,8 +37,5 @@ struct WideCard<Content: View>: View {
content() content()
.padding(padding) .padding(padding)
.background(palette.surface, in: shape) .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()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.link.support") .navigationTitle("settings.link.support")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
+1 -2
View File
@@ -39,7 +39,6 @@ struct HistoryView: View {
.background(palette.background) .background(palette.background)
.navigationTitle("history.title") .navigationTitle("history.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar { .toolbar {
if !store.entries.isEmpty { if !store.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -105,7 +104,7 @@ struct HistoryView: View {
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.listSectionSpacing(Spacing.lg) .listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(palette.background) .background(palette.background)
.contentMargins(.top, Spacing.md, for: .scrollContent) .contentMargins(.top, Spacing.md, for: .scrollContent)
+222 -177
View File
@@ -1,10 +1,11 @@
// HomeView.swift // HomeView.swift
// OSGKeyboard · Main App // OSGKeyboard · Main App
// //
// Home: logo, transient Flow connection status, usage stats, history + // Home: logo, transient Flow connection status, usage stats, account rewards,
// dictionary entry card. History/dictionary // and dictionary suggestions. History/dictionary/account destinations open
// open via push (system back) rather than bottom-tab destinations. // via push (system back) rather than bottom-tab destinations.
import OSGKeyboardHostSupport
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI import SwiftUI
import UIKit import UIKit
@@ -12,6 +13,7 @@ import UIKit
private enum HomeRoute: Hashable { private enum HomeRoute: Hashable {
case history case history
case dictionary case dictionary
case account
} }
enum FlowHomePiPStatusDescriptor: Equatable { 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 { struct HomeView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@ObservedObject private var config = ProviderConfig.shared @ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var speechHistory = SpeechHistoryStore.shared
@EnvironmentObject private var flowManager: FlowSessionManager @EnvironmentObject private var flowManager: FlowSessionManager
@State private var micStatus = AppPermissions.micStatus @State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus @State private var speechStatus = AppPermissions.speechStatus
@State private var path = NavigationPath() @State private var path = NavigationPath()
@State private var dictionarySuggestions: [RimeFrequentTerm] = [] @State private var dictionarySuggestions: [FrequentTerm] = []
@State private var pendingDictionarySuggestion: RimeFrequentTerm? @State private var pendingDictionarySuggestion: FrequentTerm?
private let appGroupStore = AppGroupStore() private let appGroupStore = AppGroupStore()
private let rimeFrequentTermStore = RimeFrequentTermStore() private let dictionaryEntryService = PersonalDictionaryEntryService()
private let frequentTermStore = FrequentTermStore()
private var usesWideLayout: Bool { private var usesWideLayout: Bool {
horizontalSizeClass == .regular horizontalSizeClass == .regular
@@ -130,6 +154,22 @@ struct HomeView: View {
return !config.isConfigured 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 { private var needsPermissionSetup: Bool {
micStatus != .granted || speechStatus != .granted micStatus != .granted || speechStatus != .granted
} }
@@ -188,9 +228,12 @@ struct HomeView: View {
HistoryView() HistoryView()
case .dictionary: case .dictionary:
PersonalDictionaryView() PersonalDictionaryView()
case .account:
AccountCenterView()
} }
} }
} }
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
.onAppear { .onAppear {
refreshPermissionStatuses() refreshPermissionStatuses()
refreshDictionarySuggestions() refreshDictionarySuggestions()
@@ -211,32 +254,6 @@ struct HomeView: View {
guard count == 0 else { return } guard count == 0 else { return }
refreshDictionarySuggestions() 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 // MARK: - Phone layout
@@ -247,7 +264,6 @@ struct HomeView: View {
let isCompact = geo.size.height < 700 let isCompact = geo.size.height < 700
let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl let logoTopPadding = isCompact ? Spacing.lg : Spacing.xxl
let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl let logoBottomPadding = isCompact ? Spacing.lg : Spacing.xxl
let extrasBottomPadding = isCompact ? Spacing.sm : Spacing.lg
ZStack(alignment: .top) { ZStack(alignment: .top) {
sessionHeaderGradient(height: gradientHeight) sessionHeaderGradient(height: gradientHeight)
@@ -265,14 +281,10 @@ struct HomeView: View {
if showsFlowConnectionCard { if showsFlowConnectionCard {
scrollStatusFooter scrollStatusFooter
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.padding(.bottom, extrasBottomPadding) .padding(.bottom, CardLayoutMetrics.sectionSpacing)
} }
HomeUsageStatsSection(layout: .stacked, compact: isCompact) homeContentSections(layout: .stacked, compact: isCompact)
.padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.md)
homeLibrarySection
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.padding(.bottom, Spacing.xl) .padding(.bottom, Spacing.xl)
} }
@@ -288,6 +300,9 @@ struct HomeView: View {
private var scrollStatusFooter: some View { private var scrollStatusFooter: some View {
setupGuidanceCard { setupGuidanceCard {
flowStatusFooter flowStatusFooter
if needsAPIKeySetup {
apiKeySetupGuidance
}
} }
} }
@@ -295,16 +310,14 @@ struct HomeView: View {
private var wideBody: some View { private var wideBody: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: Spacing.lg) { VStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
wideHeroHeader wideHeroHeader
if showsFlowConnectionCard { if showsFlowConnectionCard {
scrollStatusFooter scrollStatusFooter
} }
HomeUsageStatsSection(layout: .split) homeContentSections(layout: .split)
homeLibrarySection
} }
.padding(.horizontal, WideLayoutMetrics.pageHorizontalInset) .padding(.horizontal, WideLayoutMetrics.pageHorizontalInset)
.padding(.top, Spacing.sm) .padding(.top, Spacing.sm)
@@ -329,47 +342,38 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
// MARK: - History / dictionary cards // MARK: - Usage, account, and dictionary
/// 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
)
/// 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 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)
}
}
}
} }
} }
@@ -381,8 +385,8 @@ struct HomeView: View {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.hierarchical) .symbolRenderingMode(.monochrome)
Text("settings.personalDictionary.title") Text("settings.personalDictionary.title")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
.tracking(0.6) .tracking(0.6)
@@ -398,14 +402,22 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
if dictionarySuggestions.isEmpty { 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 { } else {
VStack(spacing: Spacing.sm) {
LazyVGrid( LazyVGrid(
columns: [ columns: [
GridItem(.adaptive(minimum: 88, maximum: 160), spacing: Spacing.sm) GridItem(
.adaptive(minimum: 88, maximum: 160),
spacing: CardLayoutMetrics.compactItemSpacing
)
], ],
alignment: .leading, alignment: .leading,
spacing: Spacing.sm spacing: CardLayoutMetrics.compactItemSpacing
) { ) {
ForEach(dictionarySuggestions) { suggestion in ForEach(dictionarySuggestions) { suggestion in
Button { Button {
@@ -413,28 +425,35 @@ struct HomeView: View {
} label: { } label: {
Text(suggestion.term) Text(suggestion.term)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.8)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.frame(maxWidth: .infinity, minHeight: 38) .frame(maxWidth: .infinity, minHeight: 38)
.background( .background(
LinearGradient( palette.textPrimary.opacity(0.08),
colors: [ in: RoundedRectangle(cornerRadius: 10, style: .continuous)
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) .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( .accessibilityHint(
AppL10n.format( AppL10n.format(
"home.card.dictionary.smart.accessibilityHint", "home.card.dictionary.smart.accessibilityHint",
@@ -443,6 +462,12 @@ struct HomeView: View {
) )
} }
} }
Text("home.card.dictionary.smart.addHint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
}
} }
} }
.padding(Spacing.md) .padding(Spacing.md)
@@ -450,55 +475,39 @@ struct HomeView: View {
.surfaceCard() .surfaceCard()
} }
/// Card shell: accent icon + label top-left, chevron trailing, custom body. private func dictionarySuggestionConfirmation(_ suggestion: FrequentTerm) -> some View {
private func homeLibraryCard<Content: View>( VStack(alignment: .leading, spacing: Spacing.md) {
titleKey: LocalizedStringKey, Text(AppL10n.format("home.card.dictionary.confirm.title", suggestion.term))
systemImage: String, .font(TypeStyle.headline)
route: HomeRoute, .foregroundStyle(palette.textPrimary)
@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 var libraryRowDivider: some View { Text(
Rectangle() AppL10n.format(
.fill(palette.divider) "home.card.dictionary.confirm.message",
.frame(height: 0.5) suggestion.commitCount
} )
)
private func libraryEmptyLine(_ key: LocalizedStringKey) -> some View {
Text(key)
.font(TypeStyle.footnote) .font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, Spacing.xs) 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() { private func refreshPermissionStatuses() {
@@ -508,32 +517,29 @@ struct HomeView: View {
private func refreshDictionarySuggestions() { private func refreshDictionarySuggestions() {
let dictionary = appGroupStore.personalDictionary let dictionary = appGroupStore.personalDictionary
let suggestions = rimeFrequentTermStore.suggestions( let suggestions = frequentTermStore.suggestions(
excludingPersonalTerms: Set(dictionary.entries.map(\.term)), excludingPersonalTerms: Set(dictionary.entries.map(\.term)),
limit: Self.dictionarySuggestionLimit limit: Self.dictionarySuggestionLimit
) )
dictionarySuggestions = suggestions.count >= Self.minimumDictionarySuggestionCount let visibleSuggestions = Array(suggestions.prefix(Self.dictionarySuggestionLimit))
? suggestions dictionarySuggestions = visibleSuggestions.count >= Self.minimumDictionarySuggestionCount
? visibleSuggestions
: [] : []
} }
private func addSuggestedTerm(_ suggestion: RimeFrequentTerm) { private func addSuggestedTerm(_ suggestion: FrequentTerm) {
var dictionary = appGroupStore.personalDictionary guard let saved = dictionaryEntryService.saveEntry(
guard let saved = dictionary.upsertManual(term: suggestion.term), term: suggestion.term,
let index = dictionary.entries.firstIndex(where: { $0.id == saved.id }) else { source: .history,
minimumUsageCount: suggestion.commitCount
) else {
return return
} }
dictionary.entries[index].usageCount = max(
dictionary.entries[index].usageCount,
suggestion.commitCount
)
dictionary.version += 1
appGroupStore.setPersonalDictionary(dictionary)
pendingDictionarySuggestion = nil pendingDictionarySuggestion = nil
refreshDictionarySuggestions() refreshDictionarySuggestions()
Task { 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 // MARK: - Top gradient
private func sessionHeaderGradient(height: CGFloat) -> some View { private func sessionHeaderGradient(height: CGFloat) -> some View {
@@ -651,6 +662,8 @@ struct HomeView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, Spacing.xs) .padding(.leading, Spacing.xs)
} else if needsAPIKeySetup { } else if needsAPIKeySetup {
// The explanatory copy and both setup paths sit below this
// compact status row so they remain readable on narrow phones.
EmptyView() EmptyView()
} else if canRetryPiP { } else if canRetryPiP {
Button { Button {
@@ -701,6 +714,51 @@ struct HomeView: View {
.animation(Motion.soft, value: flowManager.isActive) .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 { private func setupGuidanceCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
content() content()
@@ -708,10 +766,6 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .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 { 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 minimumDictionarySuggestionCount = 3
private static let dictionarySuggestionLimit = 5 private static let dictionarySuggestionLimit = 6
private static let previewTimeFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
} }
@@ -97,10 +97,6 @@ struct KeyboardPreviewSheet: View {
} }
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
private var controls: some View { private var controls: some View {
@@ -66,7 +66,6 @@ struct KeyboardPreviewStub: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(width: 28, height: 28) .frame(width: 28, height: 28)
.background(palette.surface, in: Circle()) .background(palette.surface, in: Circle())
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -84,7 +83,6 @@ struct KeyboardPreviewStub: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule()) .background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("preview.modeChip.cycle")) .accessibilityLabel(Text("preview.modeChip.cycle"))
@@ -101,7 +99,6 @@ struct KeyboardPreviewStub: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(palette.surfaceElevated, in: Capsule()) .background(palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("preview.localeChip.cycle")) .accessibilityLabel(Text("preview.localeChip.cycle"))
+6
View File
@@ -201,6 +201,12 @@ struct MainAppRoot: View {
return return
} }
refreshOfficialSkillCatalog(reason: "scenePhase.active") 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 { if config.hasCompletedOnboarding {
activateForegroundServices(reason: "scenePhase.active") activateForegroundServices(reason: "scenePhase.active")
AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active") AIHintRefreshService.refreshIfNeeded(reason: "scenePhase.active")
+5 -1
View File
@@ -15,6 +15,10 @@ struct MainTabView: View {
} }
var body: some View { var body: some View {
ZStack {
palette.background
.ignoresSafeArea()
Group { Group {
if usesSplitLayout { if usesSplitLayout {
MainSplitView(selection: $tab) MainSplitView(selection: $tab)
@@ -22,7 +26,7 @@ struct MainTabView: View {
phoneTabLayout phoneTabLayout
} }
} }
.background(palette.background) }
.ignoresSafeArea(.keyboard, edges: .bottom) .ignoresSafeArea(.keyboard, edges: .bottom)
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in .onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
tab = .settings 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) .padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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 // MARK: - Permissions
@@ -416,10 +412,6 @@ struct OnboardingExperienceView: View {
} }
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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 // MARK: - Keyboard setup
@@ -489,10 +481,6 @@ struct OnboardingExperienceView: View {
palette.surface, palette.surface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
) )
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.padding(.top, Spacing.xxl) .padding(.top, Spacing.xxl)
TextField( TextField(
@@ -762,10 +750,6 @@ struct OnboardingExperienceView: View {
} }
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240) .frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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 { private var practiceVoiceSample: some View {
@@ -822,10 +806,6 @@ struct OnboardingExperienceView: View {
} }
.padding(Spacing.md) .padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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 { private var practiceTitle: LocalizedStringKey {
@@ -918,7 +898,7 @@ struct OnboardingExperienceView: View {
.font(TypeStyle.footnote) .font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.opacity(accountSession.isSignedIn ? 0 : 1) .opacity(accountSession.isSignedIn ? 0 : 1)
.disabled(accountSession.isSignedIn) .disabled(accountSession.isSignedIn || accountSession.operation != nil)
.accessibilityHidden(accountSession.isSignedIn) .accessibilityHidden(accountSession.isSignedIn)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -927,10 +907,6 @@ struct OnboardingExperienceView: View {
palette.surface, palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
) )
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
.padding(.top, Spacing.xxl) .padding(.top, Spacing.xxl)
.accessibilityIdentifier("onboarding.loginReward.card") .accessibilityIdentifier("onboarding.loginReward.card")
} }
@@ -1483,10 +1459,6 @@ private extension View {
palette.surface, palette.surface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) 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) .padding(Spacing.md)
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) .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) .font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50) .frame(maxWidth: .infinity, minHeight: 50)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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) .foregroundStyle(palette.textPrimary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -42,7 +42,6 @@ struct OpenSourceLicensesView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.licenses.title") .navigationTitle("settings.licenses.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View { private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View {
@@ -105,6 +104,5 @@ private struct OpenSourceLicenseDetailView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle(entry.name) .navigationTitle(entry.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
+23 -35
View File
@@ -22,7 +22,7 @@ struct PersonalDictionaryView: View {
@State private var generatingAliasEntryIDs: Set<UUID> = [] @State private var generatingAliasEntryIDs: Set<UUID> = []
private let store = AppGroupStore() private let store = AppGroupStore()
private let aliasGenerator = DictionaryAliasGenerator() private let dictionaryEntryService = PersonalDictionaryEntryService()
var body: some View { var body: some View {
ZStack { ZStack {
@@ -37,7 +37,6 @@ struct PersonalDictionaryView: View {
.background(palette.background) .background(palette.background)
.navigationTitle("settings.personalDictionary.title") .navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.toolbar { .toolbar {
if !dictionary.entries.isEmpty { if !dictionary.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
@@ -76,7 +75,11 @@ struct PersonalDictionaryView: View {
initialTerm: editingEntry?.term ?? "", initialTerm: editingEntry?.term ?? "",
isEditing: editingEntry != nil isEditing: editingEntry != nil
) { term in ) { term in
saveManualEntry(term: term, editingID: editingEntry?.id) saveEntry(
term: term,
editingID: editingEntry?.id,
source: editingEntry?.source ?? .manual
)
} }
} }
.task { .task {
@@ -110,7 +113,7 @@ struct PersonalDictionaryView: View {
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
.listSectionSpacing(Spacing.lg) .listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(palette.background) .background(palette.background)
// //
@@ -138,14 +141,6 @@ struct PersonalDictionaryView: View {
Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage)) Text(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary) .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) { if generatingAliasEntryIDs.contains(entry.id) {
Text("·") Text("·")
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
@@ -231,32 +226,25 @@ struct PersonalDictionaryView: View {
// MARK: - Mutations // MARK: - Mutations
private func saveManualEntry(term: String, editingID: UUID?) { private func saveEntry(
let previousTerm = editingID.flatMap { id in term: String,
dictionary.entries.first(where: { $0.id == id })?.term editingID: UUID?,
source: PersonalDictionary.Entry.Source
) {
guard let saved = dictionaryEntryService.saveEntry(
term: term,
existingID: editingID,
source: source
) else {
return
} }
let termChanged = previousTerm.map { dictionary = saved.dictionary
$0.caseInsensitiveCompare(term) != .orderedSame if saved.shouldGenerateAliases {
} ?? true generatingAliasEntryIDs.insert(saved.entryID)
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)
} }
}
private func generateAliases(for entryID: UUID, term: String) {
generatingAliasEntryIDs.insert(entryID)
Task { Task {
let aliases = await aliasGenerator.generateAliases(for: term) dictionary = await dictionaryEntryService.finishSaving(saved)
generatingAliasEntryIDs.remove(entryID) generatingAliasEntryIDs.remove(saved.entryID)
guard !aliases.isEmpty else { return }
dictionary.updateAliases(for: entryID, aliases: aliases)
persist()
} }
} }
+9 -23
View File
@@ -24,14 +24,14 @@ struct PolishStylesView: View {
private let store = AppGroupStore() private let store = AppGroupStore()
private let columns = [ private let columns = [
GridItem(.flexible(), spacing: Spacing.sm), GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
GridItem(.flexible(), spacing: Spacing.sm) GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing)
] ]
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ScrollView { ScrollView {
CardPageContent(spacing: Spacing.xl) { CardPageContent {
styleLearningCard styleLearningCard
packGridSection( packGridSection(
title: "polishStyles.builtin.section", title: "polishStyles.builtin.section",
@@ -114,12 +114,12 @@ struct PolishStylesView: View {
return VStack(alignment: .leading, spacing: Spacing.md) { return VStack(alignment: .leading, spacing: Spacing.md) {
HStack(alignment: .top, spacing: Spacing.md) { HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: "waveform") Image(systemName: "brain")
.font(.system(size: 20, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.accent) .foregroundStyle(palette.textPrimary)
.frame(width: 42, height: 42) .frame(width: 38, height: 38)
.background( .background(
palette.accentMuted, palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
) )
@@ -191,13 +191,6 @@ struct PolishStylesView: View {
isActionAvailable ? palette.accent : palette.surfaceElevated, isActionAvailable ? palette.accent : palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) 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)) .contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -221,7 +214,7 @@ struct PolishStylesView: View {
packs: [PolishStylePack] packs: [PolishStylePack]
) -> some View { ) -> some View {
CardSection(title) { CardSection(title) {
LazyVGrid(columns: columns, spacing: Spacing.sm) { LazyVGrid(columns: columns, spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(packs) { pack in ForEach(packs) { pack in
packCard(pack) packCard(pack)
} }
@@ -274,13 +267,6 @@ struct PolishStylesView: View {
isSelected ? palette.accentMuted : palette.surface, isSelected ? palette.accentMuted : palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) 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)) .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.contextMenu { .contextMenu {
Button("polishStyles.duplicate") { Button("polishStyles.duplicate") {
@@ -18,7 +18,6 @@ struct PrivacyPolicyView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.privacy.policy") .navigationTitle("settings.privacy.policy")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
private var privacyScrollAnchor: String? { private var privacyScrollAnchor: String? {
+30 -36
View File
@@ -30,28 +30,43 @@ struct ProviderPickerSection: View {
var body: some View { var body: some View {
// Menu label // Menu label
// 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 { Menu {
Picker("", selection: providerSelection) {
ForEach(visiblePresets, id: \.id) { provider in ForEach(visiblePresets, id: \.id) { provider in
Button { Text(ProviderDisplayName.name(for: provider.id))
select(provider) .tag(provider.id)
} label: {
let name = ProviderDisplayName.name(for: provider.id)
if provider.id == selectedProviderId {
Label(name, systemImage: "checkmark")
} else {
Text(name)
}
} }
} }
.labelsHidden()
} label: { } label: {
providerChip providerChip
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.settingsListRow()
.surfaceCard(enabled: showsSurface) .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) { private func select(_ provider: LLMProvider) {
withAnimation(Motion.quick) { withAnimation(Motion.quick) {
switch role { switch role {
@@ -63,14 +78,13 @@ struct ProviderPickerSection: View {
} }
} }
/// logo + + ///
private var providerChip: some View { private var providerChip: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
providerMark(selectedProvider)
Text(ProviderDisplayName.name(for: selectedProvider.id)) Text(ProviderDisplayName.name(for: selectedProvider.id))
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1)
if selectedProvider.supportsPersonalDictionaryCloudASR { if selectedProvider.supportsPersonalDictionaryCloudASR {
personalDictionaryBadge personalDictionaryBadge
@@ -79,36 +93,14 @@ struct ProviderPickerSection: View {
streamingBadge streamingBadge
} }
Spacer(minLength: Spacing.xs)
Image(systemName: "chevron.up.chevron.down") Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
.accessibilityHidden(true)
} }
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .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 /// / GLM ASR API
private var personalDictionaryBadge: some View { private var personalDictionaryBadge: some View {
Text("settings.provider.personalDictionaryBadge") Text("settings.provider.personalDictionaryBadge")
@@ -117,6 +109,7 @@ struct ProviderPickerSection: View {
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule()) .background(palette.accentMuted, in: Capsule())
.lineLimit(1)
} }
/// Bailian / Volcengine / OpenAI Realtime utterance-level true streaming. /// Bailian / Volcengine / OpenAI Realtime utterance-level true streaming.
@@ -127,5 +120,6 @@ struct ProviderPickerSection: View {
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.padding(.vertical, 4) .padding(.vertical, 4)
.background(palette.accentMuted, in: Capsule()) .background(palette.accentMuted, in: Capsule())
.lineLimit(1)
} }
} }
+27 -20
View File
@@ -21,7 +21,7 @@ struct SettingsICloudSyncRow: View {
private let store = AppGroupStore() private let store = AppGroupStore()
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(spacing: 0) {
Toggle(isOn: toggleBinding) { Toggle(isOn: toggleBinding) {
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.appSettings.iCloudSync.title") Text("settings.appSettings.iCloudSync.title")
@@ -35,43 +35,50 @@ struct SettingsICloudSyncRow: View {
} }
.tint(palette.accent) .tint(palette.accent)
.disabled(isApplyingToggle) .disabled(isApplyingToggle)
.settingsListRow(alignment: .leading)
if isEnabled { if isEnabled {
Divider().background(palette.divider)
Button { Button {
syncNow() syncNow()
} label: { } label: {
HStack(spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.xxs) {
HStack(spacing: Spacing.sm) {
Text(syncButtonTitleKey) Text(syncButtonTitleKey)
.font(TypeStyle.caption) .font(TypeStyle.body)
Spacer(minLength: 0) Spacer(minLength: Spacing.xs)
if isSyncingNow { if isSyncingNow {
ProgressView() ProgressView()
.controlSize(.mini) .controlSize(.small)
} else if showSyncedConfirmation { } else {
Image(systemName: "checkmark") Image(
.font(.system(size: 11, weight: .semibold)) systemName: showSyncedConfirmation
? "checkmark"
: "arrow.triangle.2.circlepath"
)
.font(.system(size: 14, weight: .semibold))
} }
} }
// Fill the row so the whole strip is tappable, not just
// the caption glyphs (previously easy to miss).
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
.disabled(isSyncingNow || isApplyingToggle)
.padding(.top, Spacing.xxs)
}
if let syncErrorMessage { if let syncErrorMessage {
Text(syncErrorMessage) Text(syncErrorMessage)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(.red) .foregroundStyle(palette.danger)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding(.top, Spacing.xxs)
} }
} }
// 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) .settingsListRow(alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
.disabled(isSyncingNow || isApplyingToggle)
}
}
.onAppear { reloadFromStore() } .onAppear { reloadFromStore() }
.onReceive( .onReceive(
NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud) NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)
+19 -29
View File
@@ -180,17 +180,13 @@ struct DefaultInputModePickerRow: View {
Spacer(minLength: 8) Spacer(minLength: 8)
Menu { Menu {
Picker("", selection: defaultInputModeSelection) {
ForEach(options, id: \.id) { option in 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) Text(option.label)
.tag(option.id)
} }
} }
} .labelsHidden()
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(currentLabel) Text(currentLabel)
@@ -205,6 +201,13 @@ struct DefaultInputModePickerRow: View {
.settingsListRow() .settingsListRow()
} }
private var defaultInputModeSelection: Binding<String> {
Binding(
get: { selection.rawValue },
set: { selection = DefaultInputMode(rawValue: $0) ?? .voice }
)
}
private var currentLabel: String { private var currentLabel: String {
options.first(where: { $0.id == selection.rawValue })?.label ?? "" options.first(where: { $0.id == selection.rawValue })?.label ?? ""
} }
@@ -248,17 +251,13 @@ struct SettingsMenuPickerRow: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Menu { Menu {
ForEach(options, id: \.id) { o in Picker("", selection: $selection) {
Button { ForEach(options, id: \.id) { option in
selection = o.id Text(option.label)
} label: { .tag(option.id)
if o.id == selection {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
} }
} }
.labelsHidden()
} label: { } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Text(currentLabel) Text(currentLabel)
@@ -294,22 +293,13 @@ struct LocalePickerRow: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Menu { Menu {
Picker("", selection: $selection) {
ForEach(locales, id: \.id) { locale in ForEach(locales, id: \.id) { locale in
Button { Text(label(for: locale.id))
selection = locale.id .tag(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)
}
} }
} }
.labelsHidden()
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
// On-device badge for the currently selected locale. // On-device badge for the currently selected locale.
@@ -106,10 +106,6 @@ struct SettingsCredentialRow: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(width: 38, height: 38) .frame(width: 38, height: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) .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) .buttonStyle(.plain)
.accessibilityLabel(label) .accessibilityLabel(label)
@@ -184,20 +180,13 @@ struct SettingsModelPickerRow: View {
Button(AppL10n.string("settings.provider.modelsEmptyHint")) {} Button(AppL10n.string("settings.provider.modelsEmptyHint")) {}
.disabled(true) .disabled(true)
} else { } else {
ForEach(models, id: \.self) { modelId in Picker("", selection: modelSelection) {
Button { ForEach(models, id: \.self) { modelID in
invalidateRequest() Text(modelID)
model = modelId .tag(modelID)
message = AppL10n.format("settings.provider.modelSelected", modelId)
failed = false
} label: {
if modelId == model {
Label(modelId, systemImage: "checkmark")
} else {
Text(modelId)
}
} }
} }
.labelsHidden()
} }
} label: { } label: {
Image(systemName: "chevron.up.chevron.down") 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 { private var refreshButton: some View {
Button { Button {
runFetchModels() runFetchModels()
@@ -227,10 +228,6 @@ struct SettingsModelPickerRow: View {
} }
.frame(width: controlHeight, height: controlHeight) .frame(width: controlHeight, height: controlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) .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) .buttonStyle(.plain)
.disabled(isRunning) .disabled(isRunning)
@@ -345,10 +342,9 @@ struct SettingsProviderToolsRow: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.frame(minHeight: 34) .frame(minHeight: 34)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium)) .background(
.overlay( palette.surfaceElevated,
RoundedRectangle(cornerRadius: Radius.medium) in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
+25 -33
View File
@@ -146,10 +146,6 @@ struct AppleASRPickerRow: View {
var body: some View { var body: some View {
Toggle(isOn: usesAppleRecognition) { Toggle(isOn: usesAppleRecognition) {
HStack(spacing: Spacing.sm) {
Image(systemName: "apple.logo")
.foregroundStyle(palette.accent)
.frame(width: 28)
VStack(alignment: .leading, spacing: Spacing.xxs) { VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.asr.apple.title") Text("settings.asr.apple.title")
.font(TypeStyle.body) .font(TypeStyle.body)
@@ -159,7 +155,6 @@ struct AppleASRPickerRow: View {
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
} }
}
.tint(palette.accent) .tint(palette.accent)
.settingsListRow() .settingsListRow()
} }
@@ -213,7 +208,6 @@ struct SpeechRecognitionSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.speechRecognition.title") .navigationTitle("settings.speechRecognition.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
@@ -238,7 +232,6 @@ struct TextPolishSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.textPolish.title") .navigationTitle("settings.textPolish.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
@@ -322,7 +315,6 @@ struct GeneralSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.general.title") .navigationTitle("settings.general.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
@@ -346,7 +338,6 @@ struct AIAgentSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage)) .navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
} }
} }
@@ -368,34 +359,34 @@ struct ClipboardSettingsView: View {
CardSection("settings.clipboard.section") { CardSection("settings.clipboard.section") {
VStack(spacing: 0) { VStack(spacing: 0) {
Toggle(isOn: $config.clipboardHistoryEnabled) { Toggle(isOn: $config.clipboardHistoryEnabled) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.clipboard.history.title") Text("settings.clipboard.history.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("settings.clipboard.history.footer") Text("settings.clipboard.history.footer")
.font(.footnote) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textTertiary)
} }
} }
.tint(palette.accent) .tint(palette.accent)
.padding(.horizontal, 16) .settingsListRow()
.padding(.vertical, 12)
Divider().background(palette.divider) Divider().background(palette.divider)
Toggle(isOn: clipboardCandidateBinding) { Toggle(isOn: clipboardCandidateBinding) {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("settings.clipboard.candidate.title") Text("settings.clipboard.candidate.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("settings.clipboard.candidate.footer") Text("settings.clipboard.candidate.footer")
.font(.footnote) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textTertiary)
} }
} }
.tint(palette.accent) .tint(palette.accent)
.disabled(!config.clipboardHistoryEnabled) .disabled(!config.clipboardHistoryEnabled)
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45) .opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
.padding(.horizontal, 16) .settingsListRow()
.padding(.vertical, 12)
} }
.surfaceCard() .surfaceCard()
} }
@@ -406,7 +397,7 @@ struct ClipboardSettingsView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack(alignment: .center, spacing: Spacing.sm) { HStack(alignment: .center, spacing: Spacing.sm) {
Text("settings.clipboard.paste.body") Text("settings.clipboard.paste.body")
.font(.footnote) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if pasteAccessVerified { if pasteAccessVerified {
@@ -420,8 +411,7 @@ struct ClipboardSettingsView: View {
.accessibilityIdentifier("settings.clipboard.paste.verified") .accessibilityIdentifier("settings.clipboard.paste.verified")
} }
} }
.padding(.horizontal, 16) .settingsListRow()
.padding(.vertical, 12)
if !pasteAccessVerified { if !pasteAccessVerified {
Divider().background(palette.divider) Divider().background(palette.divider)
@@ -463,24 +453,25 @@ struct ClipboardSettingsView: View {
CardSection("settings.clipboard.storage.section") { CardSection("settings.clipboard.storage.section") {
VStack(spacing: 0) { VStack(spacing: 0) {
Text("settings.clipboard.storage.body") Text("settings.clipboard.storage.body")
.font(.footnote) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16) .settingsListRow()
.padding(.vertical, 12)
Divider().background(palette.divider) Divider().background(palette.divider)
Button(role: .destructive) { Button(role: .destructive) {
showClearConfirmation = true showClearConfirmation = true
} label: { } label: {
HStack { HStack(spacing: Spacing.sm) {
Text("settings.clipboard.clear.button") Text("settings.clipboard.clear.button")
Spacer() .font(TypeStyle.body)
Spacer(minLength: Spacing.xs)
Image(systemName: "trash") Image(systemName: "trash")
.font(.system(size: 14, weight: .semibold))
} }
.padding(.horizontal, 16) .foregroundStyle(palette.danger)
.padding(.vertical, 12) .settingsListRow()
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -494,7 +485,6 @@ struct ClipboardSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage)) .navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.confirmationDialog( .confirmationDialog(
"settings.clipboard.clear.title", "settings.clipboard.clear.title",
isPresented: $showClearConfirmation, isPresented: $showClearConfirmation,
@@ -574,12 +564,15 @@ struct AboutSettingsView: View {
CardSection("settings.analytics.section") { CardSection("settings.analytics.section") {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
Toggle( Toggle(
"settings.analytics.title",
isOn: Binding( isOn: Binding(
get: { analytics.isEnabled }, get: { analytics.isEnabled },
set: { analytics.setEnabled($0) } set: { analytics.setEnabled($0) }
) )
) ) {
Text("settings.analytics.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent) .tint(palette.accent)
.settingsListRow() .settingsListRow()
@@ -660,7 +653,6 @@ struct AboutSettingsView: View {
.background(palette.background.ignoresSafeArea()) .background(palette.background.ignoresSafeArea())
.navigationTitle("settings.about.title") .navigationTitle("settings.about.title")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
.onAppear { .onAppear {
analytics.refreshEnabledState() analytics.refreshEnabledState()
} }
+30 -215
View File
@@ -15,8 +15,8 @@ enum SettingsPresentation {
/// Routes pushed from Settings home. Value-based navigation keeps /// Routes pushed from Settings home. Value-based navigation keeps
/// destinations out of the root view tree until push important so /// destinations out of the root view tree until push important so
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home /// destination toolbar preferences do not leak onto the home screen
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles). /// (and so we avoid NavigationLink + `dismiss` freeze cycles).
private enum SettingsRoute: Hashable { private enum SettingsRoute: Hashable {
case account case account
case speechRecognition case speechRecognition
@@ -41,50 +41,42 @@ struct SettingsView: View {
// Dynamic locale list loaded from SFSpeechRecognizer on first appear. // Dynamic locale list loaded from SFSpeechRecognizer on first appear.
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = [] @State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
@State private var showResetConfirmation = false
@State private var path = NavigationPath() @State private var path = NavigationPath()
@State private var settingsScrollTarget: String?
var body: some View { var body: some View {
NavigationStack(path: $path) { NavigationStack(path: $path) {
ZStack { ZStack {
palette.background.ignoresSafeArea() palette.background.ignoresSafeArea()
ScrollViewReader { proxy in
ScrollView { ScrollView {
CardPageContent { CardPageContent {
accountEntrySection accountEntrySection
dailySection dailySection
transcriptionAndPolishSection transcriptionAndPolishSection
.id(SettingsDeepLink.aiService.rawValue)
if presentation == .tab { if presentation == .tab {
moreEntriesSection moreEntriesSection
} }
} }
.modifier(SettingsScrollBottomPadding(presentation: presentation)) .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
}
}
}
} }
.background(palette.background) .background(palette.background)
.navigationTitle("settings.title") .navigationTitle("settings.title")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
.toolbar { .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 { if presentation == .sheet {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
// Keep `dismiss` off the Settings root pairing it // Keep `dismiss` off the Settings root pairing it
@@ -98,26 +90,36 @@ struct SettingsView: View {
} }
.task { await loadDynamicLocales() } .task { await loadDynamicLocales() }
.onAppear { .onAppear {
consumeClipboardDeepLinkIfNeeded() consumeSettingsDeepLinkIfNeeded()
consumeAccountDeepLinkIfNeeded() consumeAccountDeepLinkIfNeeded()
Task { await accountSession.refreshAccountData() } Task { await accountSession.refreshAccountData() }
} }
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in .onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
consumeClipboardDeepLinkIfNeeded() consumeSettingsDeepLinkIfNeeded()
} }
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in .onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
consumeAccountDeepLinkIfNeeded() consumeAccountDeepLinkIfNeeded()
} }
} }
.navigationStackTabBarVisibility(isRoot: path.isEmpty)
} }
private func consumeClipboardDeepLinkIfNeeded() { private func consumeSettingsDeepLinkIfNeeded() {
guard SettingsDeepLink.consumePending() == .clipboard else { return } guard let link = SettingsDeepLink.consumePending() else { return }
if !path.isEmpty { if !path.isEmpty {
path = NavigationPath() path = NavigationPath()
} }
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) path.append(SettingsRoute.clipboard)
} }
}
private func consumeAccountDeepLinkIfNeeded() { private func consumeAccountDeepLinkIfNeeded() {
guard accountSession.consumeAccountCenterPresentation() else { return } guard accountSession.consumeAccountCenterPresentation() else { return }
@@ -151,7 +153,6 @@ struct SettingsView: View {
private var accountEntrySection: some View { private var accountEntrySection: some View {
CardSection("account.settings.section") { CardSection("account.settings.section") {
VStack(spacing: Spacing.sm) {
Group { Group {
switch accountSession.sessionPhase { switch accountSession.sessionPhase {
case .restoring: case .restoring:
@@ -221,119 +222,8 @@ struct SettingsView: View {
} }
} }
.surfaceCard() .surfaceCard()
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())
.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)
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()
}
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 { private var accountSettingsSubtitle: String {
switch accountSession.sessionPhase { switch accountSession.sessionPhase {
@@ -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) // MARK: - Sheet dismiss (isolated from Settings root)
private struct SettingsSheetDismissButton: View { private struct SettingsSheetDismissButton: View {
+12 -7
View File
@@ -38,17 +38,13 @@ struct TranslationPickerRow: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Spacer() Spacer()
Menu { Menu {
Picker("", selection: translationSelection) {
ForEach(TranslationLanguageCatalog.all) { language in ForEach(TranslationLanguageCatalog.all) { language in
Button {
apply(language)
} label: {
if currentSelectionId == language.id {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language)) Text(displayLabel(for: language))
.tag(language.id)
} }
} }
} .labelsHidden()
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
Text(currentLabel) Text(currentLabel)
@@ -79,6 +75,15 @@ struct TranslationPickerRow: View {
TranslationLanguageCatalog.isOff(currentSelectionId) 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 { private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId { if language.id == TranslationLanguageCatalog.offLocaleId {
return AppL10n.string("settings.translation.off") return AppL10n.string("settings.translation.off")
@@ -18,23 +18,28 @@ struct TypingInputSettingsView: View {
var body: some View { var body: some View {
List { List {
Section(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage)) { Section {
Picker( Picker(
AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage),
selection: $configuration.schema selection: $configuration.schema
) { ) {
ForEach(TypingInputSchema.allCases) { schema in ForEach(TypingInputSchema.allCases) { schema in
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage)) Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
.font(TypeStyle.body)
.tag(schema) .tag(schema)
} }
} label: {
Text(AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage))
.font(TypeStyle.body)
} }
.pickerStyle(.inline) .pickerStyle(.inline)
} header: {
Text(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
} }
Section { Section {
ForEach(PinyinFuzzyPair.allCases) { pair in ForEach(PinyinFuzzyPair.allCases) { pair in
Toggle( Toggle(
pair.displayName,
isOn: Binding( isOn: Binding(
get: { configuration.fuzzyPairs.contains(pair) }, get: { configuration.fuzzyPairs.contains(pair) },
set: { enabled in set: { enabled in
@@ -42,23 +47,30 @@ struct TypingInputSettingsView: View {
deployUpdatedSchemas() deployUpdatedSchemas()
} }
) )
) ) {
Text(pair.displayName)
.font(TypeStyle.body)
}
} }
} header: { } header: {
Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage)) Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
} footer: { } footer: {
Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage)) 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 { HStack {
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage)) Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
.font(TypeStyle.body)
Spacer() Spacer()
if isDeploying { if isDeploying {
ProgressView() ProgressView()
.controlSize(.small) .controlSize(.small)
} else { } else {
Text(statusText) Text(statusText)
.font(TypeStyle.body)
.foregroundStyle( .foregroundStyle(
hasDeploymentError ? palette.danger : palette.textSecondary hasDeploymentError ? palette.danger : palette.textSecondary
) )
@@ -68,19 +80,26 @@ struct TypingInputSettingsView: View {
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) { Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
deployUpdatedSchemas() deployUpdatedSchemas()
} }
.font(TypeStyle.body)
.disabled(isDeploying) .disabled(isDeploying)
} header: {
Text(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
} }
Section { Section {
Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) { Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) {
showClearHabitsConfirmation = true showClearHabitsConfirmation = true
} }
.font(TypeStyle.body)
.disabled(isDeploying) .disabled(isDeploying)
.foregroundStyle(palette.danger) .foregroundStyle(palette.danger)
} footer: { } footer: {
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage)) Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
.font(TypeStyle.caption2)
} }
} }
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(palette.background) .background(palette.background)
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage)) .navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
+14 -10
View File
@@ -350,7 +350,6 @@
"settings.keyboardHaptic.off" = "Off"; "settings.keyboardHaptic.off" = "Off";
"settings.keyboardHaptic.light" = "Light"; "settings.keyboardHaptic.light" = "Light";
"settings.keyboardHaptic.strong" = "Strong"; "settings.keyboardHaptic.strong" = "Strong";
"settings.cursorDragNavigation.title" = "Drag empty area to move cursor";
"settings.systemPrompt.reset" = "Reset"; "settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale"; "settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; "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.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.both" = "Microphone and speech recognition are off — voice input won't work.";
"home.setup.permission.request" = "Next"; "home.setup.permission.request" = "Next";
"home.setup.cloudIncomplete" = "Cloud engine needs an API key. Open the Settings tab."; "home.setup.cloudIncomplete" = "Cloud recognition and AI polish need API keys.";
"home.setup.polishKeyMissing" = "Add an API key in Settings to enable AI polish. Without it, raw ASR text is inserted."; "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" = "Don't see OSGKeyboard? Add it in iOS Settings.";
"home.setup.keyboardHint.dismiss" = "Got it"; "home.setup.keyboardHint.dismiss" = "Got it";
"home.flow.starting" = "Starting voice session…"; "home.flow.starting" = "Starting voice session…";
@@ -514,11 +516,12 @@
"home.preview.placeholder" = "Tap to type and test…"; "home.preview.placeholder" = "Tap to type and test…";
"home.card.history.empty" = "No voice transcripts yet"; "home.card.history.empty" = "No voice transcripts yet";
"home.card.dictionary.empty" = "No words 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.subtitle" = "Frequently used new terms discovered from your typing";
"home.card.dictionary.smart.empty" = "No suggestions yet"; "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.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.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.card.dictionary.confirm.add" = "Add to dictionary";
"home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate."; "home.wide.tagline.subtitle" = "Switch to any app and tap the keyboard mic to dictate.";
"home.wide.mode.cloud" = "Cloud"; "home.wide.mode.cloud" = "Cloud";
@@ -562,11 +565,11 @@
"skills.action.install" = "Install"; "skills.action.install" = "Install";
"skills.action.uninstall" = "Uninstall"; "skills.action.uninstall" = "Uninstall";
"skills.action.reinstallShortcut" = "Reinstall Shortcut"; "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.reply.description" = "Understand the copied text and reply naturally in its primary language.";
"skills.playfulReply.name" = "Playful reply"; "skills.playfulReply.name" = "Playful reply";
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate."; "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.replyInSourceLanguage.description" = "Understand the copied text and reply naturally in the same language.";
"skills.summarize.name" = "Summarize key points"; "skills.summarize.name" = "Summarize key points";
"skills.summarize.description" = "Summarize the content and extract facts, decisions, conclusions, and next steps when present."; "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.generating" = "Generating…";
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving."; "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.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.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.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."; "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.title" = "No words yet";
"settings.personalDictionary.empty.body" = "Add your personal dictionary to improve recognition accuracy\nSyncs across devices via iCloud"; "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.search.prompt" = "Search words";
"settings.personalDictionary.usageCount" = "%lld uses";
"settings.personalDictionary.add.title" = "Add word"; "settings.personalDictionary.add.title" = "Add word";
"settings.personalDictionary.edit.title" = "Edit word"; "settings.personalDictionary.edit.title" = "Edit word";
"settings.personalDictionary.add.field" = "Word"; "settings.personalDictionary.add.field" = "Word";
@@ -830,6 +832,8 @@
"account.signedOut.title" = "Your OSG account"; "account.signedOut.title" = "Your OSG account";
"account.signedOut.body" = "Sign in to use managed credits and referrals."; "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."; "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.apple" = "Sign in with Apple";
"account.signIn.hint" = "Creates or opens your optional OSG account."; "account.signIn.hint" = "Creates or opens your optional OSG account.";
"account.signIn.loading" = "Signing in…"; "account.signIn.loading" = "Signing in…";
+14 -10
View File
@@ -350,7 +350,6 @@
"settings.keyboardHaptic.off" = "关"; "settings.keyboardHaptic.off" = "关";
"settings.keyboardHaptic.light" = "轻"; "settings.keyboardHaptic.light" = "轻";
"settings.keyboardHaptic.strong" = "强"; "settings.keyboardHaptic.strong" = "强";
"settings.cursorDragNavigation.title" = "触摸空白区域移动光标";
"settings.systemPrompt.reset" = "重置"; "settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言"; "settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; "settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -494,8 +493,11 @@
"home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。"; "home.setup.permission.speech" = "语音识别还没授权,语音输入用不了。";
"home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。"; "home.setup.permission.both" = "麦克风和语音识别还没授权,语音输入用不了。";
"home.setup.permission.request" = "下一步"; "home.setup.permission.request" = "下一步";
"home.setup.cloudIncomplete" = "选了云端引擎,先去「设置」填 API Key。"; "home.setup.cloudIncomplete" = "云端识别和 AI 润色需要配置 API Key。";
"home.setup.polishKeyMissing" = "请先在「设置」填写 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" = "列表里没有?去系统设置里添加键盘。";
"home.setup.keyboardHint.dismiss" = "知道了"; "home.setup.keyboardHint.dismiss" = "知道了";
"home.flow.starting" = "正在启动语音会话…"; "home.flow.starting" = "正在启动语音会话…";
@@ -513,11 +515,12 @@
"home.preview.placeholder" = "点这里试试键盘"; "home.preview.placeholder" = "点这里试试键盘";
"home.card.history.empty" = "还没有语音识别记录"; "home.card.history.empty" = "还没有语音识别记录";
"home.card.dictionary.empty" = "还没有词条"; "home.card.dictionary.empty" = "还没有词条";
"home.card.dictionary.smart.subtitle" = "从 Rime 输入习惯中发现的高频新词"; "home.card.dictionary.smart.subtitle" = "从中英文输入习惯中发现的高频新词";
"home.card.dictionary.smart.empty" = "暂无推荐词"; "home.card.dictionary.smart.empty" = "暂无推荐词";
"home.card.dictionary.smart.addHint" = "点击添加常用词到个性词库";
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。"; "home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?"; "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.card.dictionary.confirm.add" = "加入词库";
"home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。"; "home.wide.tagline.subtitle" = "切换到任意 App,点键盘麦克风即可听写。";
"home.wide.mode.cloud" = "云端"; "home.wide.mode.cloud" = "云端";
@@ -561,11 +564,11 @@
"skills.action.install" = "安装"; "skills.action.install" = "安装";
"skills.action.uninstall" = "卸载"; "skills.action.uninstall" = "卸载";
"skills.action.reinstallShortcut" = "重新安装捷径"; "skills.action.reinstallShortcut" = "重新安装捷径";
"skills.reply.name" = "原语言回复"; "skills.reply.name" = "回复";
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。"; "skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
"skills.playfulReply.name" = "俏皮回复"; "skills.playfulReply.name" = "俏皮回复";
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。"; "skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
"skills.replyInSourceLanguage.name" = "用原语言回复"; "skills.replyInSourceLanguage.name" = "回复";
"skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。"; "skills.replyInSourceLanguage.description" = "理解复制内容,并使用相同语言自然回复。";
"skills.summarize.name" = "总结要点"; "skills.summarize.name" = "总结要点";
"skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。"; "skills.summarize.description" = "总结核心内容,并按需提炼事实、决定、结论和下一步。";
@@ -659,7 +662,7 @@
"polishStyles.learn.generating" = "生成中…"; "polishStyles.learn.generating" = "生成中…";
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; "polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 5,000 个有效字符后再生成。"; "polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。"; "polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。"; "polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
@@ -723,7 +726,6 @@
"settings.personalDictionary.empty.title" = "还没有词条"; "settings.personalDictionary.empty.title" = "还没有词条";
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步"; "settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
"settings.personalDictionary.search.prompt" = "搜索词条"; "settings.personalDictionary.search.prompt" = "搜索词条";
"settings.personalDictionary.usageCount" = "使用 %lld 次";
"settings.personalDictionary.add.title" = "添加词条"; "settings.personalDictionary.add.title" = "添加词条";
"settings.personalDictionary.edit.title" = "编辑词条"; "settings.personalDictionary.edit.title" = "编辑词条";
"settings.personalDictionary.add.field" = "词条"; "settings.personalDictionary.add.field" = "词条";
@@ -829,6 +831,8 @@
"account.signedOut.title" = "OSG 账号"; "account.signedOut.title" = "OSG 账号";
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。"; "account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。"; "account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
"home.accountRewards.signedOut.title" = "登录领免费积分";
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分。";
"account.signIn.apple" = "通过 Apple 登录"; "account.signIn.apple" = "通过 Apple 登录";
"account.signIn.hint" = "创建或打开可选的 OSG 账号。"; "account.signIn.hint" = "创建或打开可选的 OSG 账号。";
"account.signIn.loading" = "正在登录…"; "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 /// One random ID per keyboard presentation. The repository splits this ID
/// into independent UTC-day fragments when a presentation crosses midnight. /// into independent UTC-day fragments when a presentation crosses midnight.
private var keyboardUsageSessionID = UUID() 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 { private var memoryTelemetryContext: String {
let language = typingSessionStorage?.language.rawValue ?? "-" let language = typingSessionStorage?.language.rawValue ?? "-"
@@ -208,6 +213,9 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
isKeyboardPresentationActive = false
typingDocumentPresentationID = nil
typingSessionStorage?.endDocumentPresentation()
AnalyticsExtensionService.shared.keyboardWillDisappear() AnalyticsExtensionService.shared.keyboardWillDisappear()
assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask?.cancel()
assistantFieldActionRefreshTask = nil assistantFieldActionRefreshTask = nil
@@ -243,8 +251,8 @@ public final class KeyboardViewController: UIInputViewController {
if !preserve { if !preserve {
prepareSurfaceForNextPresentation() prepareSurfaceForNextPresentation()
} }
if state.surface == .typing { if state.surface != .typing {
typingSession.leaveTypingMode() deactivateTypingSurface()
} }
if preserve { if preserve {
return return
@@ -255,6 +263,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) { public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
isKeyboardPresentationActive = true
OSGDiag.log( OSGDiag.log(
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) " "KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())", + "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
@@ -263,6 +272,7 @@ public final class KeyboardViewController: UIInputViewController {
recordMemory("KVC.viewWillAppear.begin") recordMemory("KVC.viewWillAppear.begin")
setNeedsUpdateOfScreenEdgesDeferringSystemGestures() setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior() configureDictationBehavior()
state.hasFullAccess = hasFullAccess
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
// Refresh only Flow/config state; edit targets come from verified OSG insertions. // Refresh only Flow/config state; edit targets come from verified OSG insertions.
flowCoordinator.refreshSessionState() flowCoordinator.refreshSessionState()
@@ -278,8 +288,7 @@ public final class KeyboardViewController: UIInputViewController {
recordMemory("KVC.viewWillAppear.afterHaptics") recordMemory("KVC.viewWillAppear.afterHaptics")
if state.surface == .typing { if state.surface == .typing {
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot") OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
typingSession.enterTypingMode() activateTypingPresentationIfNeeded()
refreshEnglishSupplementaryLexicon()
recordMemory("KVC.viewWillAppear.afterTypingEnter") recordMemory("KVC.viewWillAppear.afterTypingEnter")
} }
clipboardCapture.keyboardDidAppear() clipboardCapture.keyboardDidAppear()
@@ -322,6 +331,7 @@ public final class KeyboardViewController: UIInputViewController {
heightPhase = .presented heightPhase = .presented
lockPresentedKeyboardHeight() lockPresentedKeyboardHeight()
refreshReturnKeyRole() refreshReturnKeyRole()
scheduleTypingDocumentSynchronization()
// Presentation math is locked before arming the PiP handoff. // Presentation math is locked before arming the PiP handoff.
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self, self.heightPhase == .presented else { return } guard let self, self.heightPhase == .presented else { return }
@@ -357,7 +367,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func textDidChange(_ textInput: UITextInput?) { public override func textDidChange(_ textInput: UITextInput?) {
super.textDidChange(textInput) super.textDidChange(textInput)
refreshReturnKeyRole() refreshReturnKeyRole()
typingSession.synchronizeEnglishDocumentContext() synchronizeTypingDocumentContext()
textInserter?.refreshEditingAvailability() textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext() lastInputEditCoordinator?.refreshContext()
} }
@@ -365,7 +375,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func selectionDidChange(_ textInput: UITextInput?) { public override func selectionDidChange(_ textInput: UITextInput?) {
super.selectionDidChange(textInput) super.selectionDidChange(textInput)
refreshReturnKeyRole() refreshReturnKeyRole()
typingSession.synchronizeEnglishDocumentContext(caretMoved: true) synchronizeTypingDocumentContext(caretMoved: true)
textInserter?.refreshEditingAvailability() textInserter?.refreshEditingAvailability()
lastInputEditCoordinator?.refreshContext() lastInputEditCoordinator?.refreshContext()
} }
@@ -395,10 +405,9 @@ public final class KeyboardViewController: UIInputViewController {
) )
flowCoordinator.cancelPipelineUnlessAwaitingResult() flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing { if state.surface == .typing {
typingSession.leaveTypingMode()
applySurface(.voice) applySurface(.voice)
} else { } else {
typingSession.leaveTypingMode() deactivateTypingSurface()
} }
} }
@@ -423,9 +432,15 @@ public final class KeyboardViewController: UIInputViewController {
self?.insertTextIntoDocument(text, source: source) self?.insertTextIntoDocument(text, source: source)
}, },
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() }, 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() }, 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() }, scheduleAutoClearError: { [weak self] in self?.scheduleAutoClearError() },
editHintScheduler: editHintScheduler editHintScheduler: editHintScheduler
) )
@@ -439,6 +454,7 @@ public final class KeyboardViewController: UIInputViewController {
onConfigChanged: { [weak self] in onConfigChanged: { [weak self] in
// Only an already-live typing session can be showing the setup // Only an already-live typing session can be showing the setup
// error; never force-create one just to retry. // error; never force-create one just to retry.
self?.typingSessionStorage?.reloadPersonalDictionaryTerms()
self?.typingSessionStorage?.retryPrepareAfterResourceDeployment() self?.typingSessionStorage?.retryPrepareAfterResourceDeployment()
} }
) )
@@ -502,7 +518,7 @@ public final class KeyboardViewController: UIInputViewController {
clipboardCapture = ClipboardCaptureCoordinator(state: state) clipboardCapture = ClipboardCaptureCoordinator(state: state)
clipboardCapture.configure( clipboardCapture.configure(
isSecure: { [weak self] in isSecure: { [weak self] in
self?.textDocumentProxy.isSecureTextEntry ?? false self?.textDocumentProxy.isSecureTextEntry ?? true
}, },
hasFullAccess: { [weak self] in hasFullAccess: { [weak self] in
self?.hasFullAccess ?? false self?.hasFullAccess ?? false
@@ -666,8 +682,14 @@ public final class KeyboardViewController: UIInputViewController {
state.$surface state.$surface
.receive(on: RunLoop.main) .receive(on: RunLoop.main)
.sink { [weak self] _ in .sink { [weak self] surface in
self?.refreshKeyboardHeight() guard let self else { return }
if surface == .typing {
self.activateTypingPresentationIfNeeded()
} else {
self.deactivateTypingSurface()
}
self.refreshKeyboardHeight()
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
@@ -685,6 +707,9 @@ public final class KeyboardViewController: UIInputViewController {
return return
} }
guard state.surface != surface else { guard state.surface != surface else {
if surface == .typing {
activateTypingPresentationIfNeeded()
}
refreshKeyboardHeight() refreshKeyboardHeight()
return return
} }
@@ -695,14 +720,68 @@ public final class KeyboardViewController: UIInputViewController {
state.surface = surface state.surface = surface
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)") recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
if surface == .typing { if surface == .typing {
typingSession.enterTypingMode() activateTypingPresentationIfNeeded()
refreshEnglishSupplementaryLexicon()
} else { } else {
typingSession.leaveTypingMode() deactivateTypingSurface()
} }
refreshKeyboardHeight() 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() { private func applyPreferredSurfaceOnOpen() {
let preference = TypingInputConfiguration.preferredOpenPreference() let preference = TypingInputConfiguration.preferredOpenPreference()
let resolved = KeyboardOpenSurfacePolicy.resolve( let resolved = KeyboardOpenSurfacePolicy.resolve(
@@ -716,10 +795,10 @@ public final class KeyboardViewController: UIInputViewController {
+ "locksTyping=\(state.locksTypingSurface ? 1 : 0)", + "locksTyping=\(state.locksTypingSurface ? 1 : 0)",
category: "boot" category: "boot"
) )
applySurface(resolved)
if resolved == .typing, let language = preference.typingLanguage { if resolved == .typing, let language = preference.typingLanguage {
_ = typingSession.setLanguage(language) _ = typingSession.setLanguage(language)
} }
applySurface(resolved)
} }
/// When not remembering, snap to the static open preference while hidden /// When not remembering, snap to the static open preference while hidden
@@ -842,24 +921,31 @@ public final class KeyboardViewController: UIInputViewController {
assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask?.cancel()
assistantFieldActionRefreshTask = nil assistantFieldActionRefreshTask = nil
refreshAssistantFieldAction() 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) state.setSecureTextEntry(isSecure)
clipboardCapture?.secureEntryDidChange(isSecure: isSecure) clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
// Secure fields must not run English autocomplete / autocorrect / learning. // Secure fields must not run English autocomplete / autocorrect / learning.
typingSession.suggestionsEnabled = !isSecure typingSessionStorage?.suggestionsEnabled = !isSecure
typingSession.syncAutocapitalization() if !isSecure {
typingSessionStorage?.syncAutocapitalization()
}
} }
private func installTypingContextProviders() { private func installTypingContextProviders() {
typingSession.precedingTextProvider = { [weak self] in 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 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 typingSession.autocapitalizationModeProvider = { [weak self] in
Self.typingAutocapitalizationMode( guard let self, self.textDocumentProxy.isSecureTextEntry == false else { return .none }
for: self?.textDocumentProxy.autocapitalizationType ?? .sentences return Self.typingAutocapitalizationMode(
for: self.textDocumentProxy.autocapitalizationType ?? .sentences
) )
} }
} }
@@ -1037,6 +1123,7 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - App context // MARK: - App context
private func detectAndStoreAppContext() { private func detectAndStoreAppContext() {
guard textDocumentProxy.isSecureTextEntry == false else { return }
let preceding = textDocumentProxy.documentContextBeforeInput let preceding = textDocumentProxy.documentContextBeforeInput
let store = AppGroupStore() let store = AppGroupStore()
let detector = AppContextDetector() let detector = AppContextDetector()
@@ -1048,9 +1135,9 @@ public final class KeyboardViewController: UIInputViewController {
} }
private func captureFieldContext() -> FlowFieldContext { private func captureFieldContext() -> FlowFieldContext {
let isSecure = textDocumentProxy.isSecureTextEntry ?? false let isSecure = textDocumentProxy.isSecureTextEntry ?? true
let preceding = textDocumentProxy.documentContextBeforeInput let preceding = isSecure ? nil : textDocumentProxy.documentContextBeforeInput
let following = textDocumentProxy.documentContextAfterInput let following = isSecure ? nil : textDocumentProxy.documentContextAfterInput
let isAvailable = preceding != nil || following != nil let isAvailable = preceding != nil || following != nil
let isEmpty = isAvailable && (preceding ?? "").isEmpty && (following ?? "").isEmpty let isEmpty = isAvailable && (preceding ?? "").isEmpty && (following ?? "").isEmpty
@@ -88,6 +88,7 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return } guard AppGroup.isAvailable else { return }
let store = AppGroupStore() let store = AppGroupStore()
state.engineMode = store.engineMode state.engineMode = store.engineMode
state.localeId = store.localeId
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect( let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
until: protectTranslationUntil until: protectTranslationUntil
) )
@@ -12,7 +12,7 @@ struct CandidateExpandGridView: UIViewRepresentable {
var candidates: [TypingCandidate] var candidates: [TypingCandidate]
var textColor: UIColor var textColor: UIColor
var dividerColor: UIColor var dividerColor: UIColor
var onSelect: (Int) -> Void var onSelect: (TypingCandidate) -> Void
func makeCoordinator() -> Coordinator { func makeCoordinator() -> Coordinator {
Coordinator(onSelect: onSelect) Coordinator(onSelect: onSelect)
@@ -73,10 +73,10 @@ struct CandidateExpandGridView: UIViewRepresentable {
var candidates: [TypingCandidate] = [] var candidates: [TypingCandidate] = []
var textColor: UIColor = .label var textColor: UIColor = .label
var dividerColor: UIColor = .separator var dividerColor: UIColor = .separator
var onSelect: (Int) -> Void var onSelect: (TypingCandidate) -> Void
weak var collectionView: UICollectionView? weak var collectionView: UICollectionView?
init(onSelect: @escaping (Int) -> Void) { init(onSelect: @escaping (TypingCandidate) -> Void) {
self.onSelect = onSelect self.onSelect = onSelect
} }
@@ -105,7 +105,8 @@ struct CandidateExpandGridView: UIViewRepresentable {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
collectionView.deselectItem(at: indexPath, animated: false) 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 { private func isLastRow(index: Int) -> Bool {
@@ -82,11 +82,6 @@ struct KeyboardSurfaceRoot: View {
// lingers (a slow Universal Clipboard read, a system alert). // lingers (a slow Universal Clipboard read, a system alert).
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
.animation(.easeInOut(duration: 0.15), value: state.surface) .animation(.easeInOut(duration: 0.15), value: state.surface)
.onChange(of: state.surface) { _, newSurface in
if newSurface != .typing {
typing.leaveTypingMode()
}
}
} }
private func wrappedInsert(_ text: String) { private func wrappedInsert(_ text: String) {
@@ -124,7 +119,9 @@ struct KeyboardSurfaceRoot: View {
onClear: state.clearClipboardHistory, onClear: state.clearClipboardHistory,
onInsert: { state.insertClipboardText($0) }, onInsert: { state.insertClipboardText($0) },
onDelete: { state.deleteClipboardHistoryEntry($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 typingKeySurface
.padding(.top, TypingLayoutMetrics.verticalKeySpacing) .padding(.top, TypingLayoutMetrics.verticalKeySpacing)
.opacity(expanded ? 0 : 1) .opacity(expanded ? 0 : 1)
.allowsHitTesting(!expanded) .allowsHitTesting(!expanded && canAcceptTypingKeys)
.accessibilityHidden(expanded) .accessibilityHidden(expanded || !canAcceptTypingKeys)
if expanded || candidatePanelMounted { if expanded || candidatePanelMounted {
expandedCandidatePanel expandedCandidatePanel
@@ -141,6 +141,14 @@ struct TypingRootView: View {
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty !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 { private var idleTopBar: some View {
ZStack { ZStack {
KeyboardTopControls( KeyboardTopControls(
@@ -157,6 +165,10 @@ struct TypingRootView: View {
if let err = typing.lastError { if let err = typing.lastError {
typingErrorLabel(err) typingErrorLabel(err)
} else if isChineseEnginePreparing {
ProgressView()
.controlSize(.small)
.accessibilityLabel(ExtL10n.text("keyboard.typing.preparingA11y"))
} }
// iOS-style editing cluster (undo / redo / copy / cut) iPad only, // iOS-style editing cluster (undo / redo / copy / cut) iPad only,
@@ -238,16 +250,17 @@ struct TypingRootView: View {
.fill(palette.dividerStrong) .fill(palette.dividerStrong)
.frame(width: 1, height: 18) .frame(width: 1, height: 18)
} }
englishQuickTypeSlot(candidate, index: index) englishQuickTypeSlot(candidate)
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.leading, KeyboardTopBarMetrics.nestedHorizontalInset) .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 label = candidate.isQuoted ? "\"\(candidate.text)\"" : candidate.text
let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular let weight: Font.Weight = candidate.role == .correction ? .semibold : .regular
let candidateRevision = typing.candidateRevision
return Text(label) return Text(label)
.font(.system(size: 17, weight: weight)) .font(.system(size: 17, weight: weight))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
@@ -256,20 +269,32 @@ struct TypingRootView: View {
.frame(maxWidth: .infinity, minHeight: 40) .frame(maxWidth: .infinity, minHeight: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
apply(typing.selectCandidate(at: index)) apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
} }
.accessibilityAddTraits(.isButton) .accessibilityAddTraits(.isButton)
.accessibilityLabel(candidate.text) .accessibilityLabel(candidate.text)
} }
private var chineseCandidateStrip: some View { private var chineseCandidateStrip: some View {
ScrollView(.horizontal, showsIndicators: false) { let candidateRevision = typing.candidateRevision
return ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
if typing.composition.candidates.isEmpty { if typing.composition.candidates.isEmpty {
selectedCandidateLabel(text: typing.composition.preedit) selectedCandidateLabel(text: typing.composition.preedit)
} else if typing.isCandidatePanelExpanded { } else if typing.isCandidatePanelExpanded,
candidateChip(text: typing.composition.candidates[0].text) { let candidate = typing.composition.candidates.first {
apply(typing.selectCandidate(at: 0)) candidateChip(text: candidate.text) {
apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
} }
} else { } else {
ForEach( ForEach(
@@ -282,7 +307,12 @@ struct TypingRootView: View {
) { index, candidate in ) { index, candidate in
if index == 0 { if index == 0 {
candidateChip(text: candidate.text) { candidateChip(text: candidate.text) {
apply(typing.selectCandidate(at: index)) apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
} }
} else { } else {
Text(candidate.text) Text(candidate.text)
@@ -292,7 +322,12 @@ struct TypingRootView: View {
.frame(height: 40) .frame(height: 40)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
apply(typing.selectCandidate(at: index)) apply(
typing.selectCandidate(
id: candidate.id,
candidateRevision: candidateRevision
)
)
} }
.accessibilityAddTraits(.isButton) .accessibilityAddTraits(.isButton)
.accessibilityLabel(candidate.text) .accessibilityLabel(candidate.text)
@@ -393,15 +428,21 @@ struct TypingRootView: View {
/// UIKit-recycled labels no SwiftUI Button per candidate. /// UIKit-recycled labels no SwiftUI Button per candidate.
private var expandedCandidatePanel: some View { private var expandedCandidatePanel: some View {
CandidateExpandGridView( let candidateRevision = typing.candidateRevision
return CandidateExpandGridView(
candidates: typing.composition.candidates, candidates: typing.composition.candidates,
textColor: UIColor(keyTextColor), textColor: UIColor(keyTextColor),
dividerColor: UIColor(palette.dividerStrong), dividerColor: UIColor(palette.dividerStrong),
onSelect: { index in onSelect: { candidate in
var transaction = Transaction() var transaction = Transaction()
transaction.disablesAnimations = true transaction.disablesAnimations = true
withTransaction(transaction) { 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) { private func commitTypingKey(_ key: TypingKeyHitTarget) {
guard canAcceptTypingKeys else { return }
switch key.id { switch key.id {
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue: case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
typing.setPage(typing.page == .letters ? .numbers : .letters) typing.setPage(typing.page == .letters ? .numbers : .letters)
+5 -3
View File
@@ -581,10 +581,12 @@ struct AIKeyboardView: View {
if let expectedSkillID = oobeExpectedSkillID { if let expectedSkillID = oobeExpectedSkillID {
return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID } return AIClipboardSkillCatalog.catalog.filter { $0.id == expectedSkillID }
} }
guard let newest = clipboardHistory.newestEntry, guard let newest = clipboardHistory.newestEntry else { return [] }
let snapshot = semanticRanking.snapshot, guard let snapshot = semanticRanking.snapshot,
snapshot.entryID == newest.id else { snapshot.entryID == newest.id else {
return [] return state.clipboardSkillCatalog.filter {
$0.id == AIClipboardSkillCatalog.replyID
}
} }
return ClipboardSkillSemanticRanker.recommended( return ClipboardSkillSemanticRanker.recommended(
skills: state.clipboardSkillCatalog, skills: state.clipboardSkillCatalog,
+4 -4
View File
@@ -152,11 +152,10 @@
"keyboard.redoA11y" = "Redo"; "keyboard.redoA11y" = "Redo";
"keyboard.copyA11y" = "Copy"; "keyboard.copyA11y" = "Copy";
"keyboard.cutA11y" = "Cut"; "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.nextKeyboardA11y" = "Next keyboard";
"keyboard.nextKeyboardA11yHint" = "Tap to switch to the next keyboard. Touch and hold to see all keyboards."; "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.setupA11yHint" = "Open OSGKeyboard to finish input method setup.";
"keyboard.typing.preparingA11y" = "Preparing Chinese input";
/* Flow session (keyboard) */ /* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Voice session off"; "keyboard.flow.sessionInactive" = "Voice session off";
@@ -211,6 +210,7 @@
"keyboard.clipboard.guide.cta" = "Open Settings"; "keyboard.clipboard.guide.cta" = "Open Settings";
"keyboard.clipboard.panel.title" = "Clipboard"; "keyboard.clipboard.panel.title" = "Clipboard";
"keyboard.clipboard.panel.empty" = "No history yet"; "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.delete" = "Delete";
"keyboard.clipboard.panel.close" = "Close clipboard"; "keyboard.clipboard.panel.close" = "Close clipboard";
"keyboard.clipboard.panel.closeHint" = "Return to the keyboard."; "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.requestTimeout" = "AI response timed out. Try again";
"keyboard.ai.error.requestFailed" = "AI response failed. 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.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.playfulReply" = "Playful";
"keyboard.ai.skill.replyInSourceLanguage" = "Same language"; "keyboard.ai.skill.replyInSourceLanguage" = "Reply";
"keyboard.ai.skill.summarize" = "Key points"; "keyboard.ai.skill.summarize" = "Key points";
"keyboard.ai.skill.openLink" = "Open"; "keyboard.ai.skill.openLink" = "Open";
"keyboard.ai.skill.summarizeWebPage" = "Web summary"; "keyboard.ai.skill.summarizeWebPage" = "Web summary";
@@ -152,11 +152,10 @@
"keyboard.redoA11y" = "重做"; "keyboard.redoA11y" = "重做";
"keyboard.copyA11y" = "拷贝"; "keyboard.copyA11y" = "拷贝";
"keyboard.cutA11y" = "剪切"; "keyboard.cutA11y" = "剪切";
"keyboard.cursorDrag.hint" = "按住并拖动以移动光标";
"keyboard.cursorDrag.centerHint" = "拖动移动光标";
"keyboard.nextKeyboardA11y" = "切换键盘"; "keyboard.nextKeyboardA11y" = "切换键盘";
"keyboard.nextKeyboardA11yHint" = "轻点切换到下一个键盘;长按查看全部键盘。"; "keyboard.nextKeyboardA11yHint" = "轻点切换到下一个键盘;长按查看全部键盘。";
"keyboard.typing.setupA11yHint" = "打开 OSGKeyboard 完成输入法初始化。"; "keyboard.typing.setupA11yHint" = "打开 OSGKeyboard 完成输入法初始化。";
"keyboard.typing.preparingA11y" = "正在准备中文输入";
/* Flow session (keyboard) */ /* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "语音会话未启动"; "keyboard.flow.sessionInactive" = "语音会话未启动";
@@ -211,6 +210,7 @@
"keyboard.clipboard.guide.cta" = "去设置开启"; "keyboard.clipboard.guide.cta" = "去设置开启";
"keyboard.clipboard.panel.title" = "剪贴板"; "keyboard.clipboard.panel.title" = "剪贴板";
"keyboard.clipboard.panel.empty" = "暂无历史记录"; "keyboard.clipboard.panel.empty" = "暂无历史记录";
"keyboard.clipboard.panel.fullAccessHint" = "「允许完全访问」已关闭,新复制内容将暂停采集,已有历史仍可使用。";
"keyboard.clipboard.panel.delete" = "删除"; "keyboard.clipboard.panel.delete" = "删除";
"keyboard.clipboard.panel.close" = "关闭剪贴板"; "keyboard.clipboard.panel.close" = "关闭剪贴板";
"keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。"; "keyboard.clipboard.panel.closeHint" = "返回键盘输入界面。";
@@ -319,9 +319,9 @@
"keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试"; "keyboard.ai.error.requestTimeout" = "AI 回答超时,请重试";
"keyboard.ai.error.requestFailed" = "AI 回答失败,请重试"; "keyboard.ai.error.requestFailed" = "AI 回答失败,请重试";
"keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本"; "keyboard.ai.error.clipboardUnavailable" = "剪贴板建议已过期,请重新复制文本";
"keyboard.ai.skill.reply" = "原语言回复"; "keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.playfulReply" = "俏皮回复"; "keyboard.ai.skill.playfulReply" = "俏皮回复";
"keyboard.ai.skill.replyInSourceLanguage" = "原语言回复"; "keyboard.ai.skill.replyInSourceLanguage" = "回复";
"keyboard.ai.skill.summarize" = "总结要点"; "keyboard.ai.skill.summarize" = "总结要点";
"keyboard.ai.skill.openLink" = "打开"; "keyboard.ai.skill.openLink" = "打开";
"keyboard.ai.skill.summarizeWebPage" = "总结网页"; "keyboard.ai.skill.summarizeWebPage" = "总结网页";
@@ -84,6 +84,69 @@ final class CandidatePanelExpandTests: XCTestCase {
XCTAssertFalse(typing.isCandidatePanelExpanded) XCTAssertFalse(typing.isCandidatePanelExpanded)
XCTAssertTrue(typing.composition.candidates.isEmpty) 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. // Minimal engine that returns a two-candidate snapshot after any letter.
@@ -94,9 +157,17 @@ private final class StubRimeEngine: RimeEngineBridging {
var schema: TypingInputSchema = .fullPinyin var schema: TypingInputSchema = .fullPinyin
/// Optional override applied on the next processBackspace. /// Optional override applied on the next processBackspace.
var nextComposition: TypingComposition? 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() { func teardown() {
teardownCallCount += 1
composition = .empty composition = .empty
} }
@@ -116,8 +187,8 @@ private final class StubRimeEngine: RimeEngineBridging {
composition = TypingComposition( composition = TypingComposition(
preedit: String(character), preedit: String(character),
candidates: [ candidates: [
TypingCandidate(text: ""), TypingCandidate(id: "first", text: ""),
TypingCandidate(text: "") TypingCandidate(id: "second", text: "")
] ]
) )
return nil return nil
@@ -158,6 +229,7 @@ private final class StubRimeEngine: RimeEngineBridging {
} }
func clearComposition() { func clearComposition() {
clearCompositionCallCount += 1
composition = .empty composition = .empty
} }
} }
+142 -1
View File
@@ -179,6 +179,79 @@ final class EnglishTypingTests: XCTestCase {
XCTAssertEqual(typing.composition.preedit, "h") 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() { func testEnglishTypingHotwordsFilterOutChineseTerms() {
var dictionary = PersonalDictionary() var dictionary = PersonalDictionary()
_ = dictionary.upsertManual(term: "张三") _ = 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() { func testQWERTYNeighborsIncludeDiagonals() {
let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true) let aroundG = EnglishQWERTYProximity.neighbors(of: "g", includingSelf: true)
XCTAssertTrue(aroundG.contains("t")) XCTAssertTrue(aroundG.contains("t"))
@@ -570,6 +707,7 @@ final class EnglishTypingTests: XCTestCase {
@MainActor @MainActor
private func makeTypingSession( private func makeTypingSession(
learningStore: EnglishLearningStore? = nil, learningStore: EnglishLearningStore? = nil,
frequentTermStore: FrequentTermStore? = nil,
language: TypingInputLanguage = .english language: TypingInputLanguage = .english
) -> TypingSessionController { ) -> TypingSessionController {
let store = learningStore ?? EnglishLearningStore( let store = learningStore ?? EnglishLearningStore(
@@ -577,7 +715,10 @@ final class EnglishTypingTests: XCTestCase {
) )
let typing = TypingSessionController( let typing = TypingSessionController(
engine: { NoopRimeEngine() }, engine: { NoopRimeEngine() },
learningStore: store learningStore: store,
frequentTermStore: frequentTermStore ?? FrequentTermStore(
defaults: UserDefaults(suiteName: "frequent.tests.\(UUID().uuidString)")!
)
) )
typing.suggestionsEnabled = true typing.suggestionsEnabled = true
_ = typing.setLanguage(language) _ = typing.setLanguage(language)
@@ -36,6 +36,7 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(s.level, 0) XCTAssertEqual(s.level, 0)
XCTAssertFalse(s.onDeviceSupported) XCTAssertFalse(s.onDeviceSupported)
XCTAssertFalse(s.undoAvailable) XCTAssertFalse(s.undoAvailable)
XCTAssertFalse(s.hasFullAccess)
} }
func testPhaseTransitionsIdleToRequestingPermissionsAndBack() { func testPhaseTransitionsIdleToRequestingPermissionsAndBack() {
@@ -145,6 +146,20 @@ final class KeyboardStateTests: XCTestCase {
XCTAssertEqual(state.clipboardOverlay, .none) 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() { func testResolvedSkillCopyPublishesWhenIDsStayStable() {
let state = KeyboardState() let state = KeyboardState()
state.enabledClipboardSkillIDs = ["official.rewrite"] 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 // UsageStatsCluster.swift
// OSGKeyboard · HostSupport // 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 // Callers observe their store and pass plain values Shared stays unbound
// from platform singletons. // 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`. // (iOS Home glass preview field). Mac / plain call sites keep `EmptyView`.
import SwiftUI import SwiftUI
@@ -14,15 +14,21 @@ import OSGKeyboardShared
#endif #endif
public enum UsageStatsClusterLayout: Sendable, Equatable { 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 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 case stacked
/// 2×2 沿 HomeStatsCard /// 2×2 沿 HomeStatsCard
public static let compactGridHeight: CGFloat = 166 public static let compactGridHeight: CGFloat = 166
} }
public enum UsageStatsClusterContent: Sendable, Equatable {
case all
case calendar
case metrics
}
public struct UsageStatsCluster<Header: View>: View { public struct UsageStatsCluster<Header: View>: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@@ -33,9 +39,12 @@ public struct UsageStatsCluster<Header: View>: View {
public let dictationDurationSeconds: TimeInterval public let dictationDurationSeconds: TimeInterval
public let translationCharacterCount: Int public let translationCharacterCount: Int
public let dictionaryTermCount: Int public let dictionaryTermCount: Int
/// iPhone SE stacked /// iPhone SE
public let compact: Bool public let compact: Bool
public let content: UsageStatsClusterContent
private let header: Header private let header: Header
private let onOpenHistory: (() -> Void)?
private let onOpenDictionary: (() -> Void)?
public init( public init(
layout: UsageStatsClusterLayout, layout: UsageStatsClusterLayout,
@@ -46,6 +55,9 @@ public struct UsageStatsCluster<Header: View>: View {
translationCharacterCount: Int, translationCharacterCount: Int,
dictionaryTermCount: Int, dictionaryTermCount: Int,
compact: Bool = false, compact: Bool = false,
content: UsageStatsClusterContent = .all,
onOpenHistory: (() -> Void)? = nil,
onOpenDictionary: (() -> Void)? = nil,
@ViewBuilder header: () -> Header @ViewBuilder header: () -> Header
) { ) {
self.layout = layout self.layout = layout
@@ -56,22 +68,33 @@ public struct UsageStatsCluster<Header: View>: View {
self.translationCharacterCount = translationCharacterCount self.translationCharacterCount = translationCharacterCount
self.dictionaryTermCount = dictionaryTermCount self.dictionaryTermCount = dictionaryTermCount
self.compact = compact self.compact = compact
self.content = content
self.onOpenHistory = onOpenHistory
self.onOpenDictionary = onOpenDictionary
self.header = header() self.header = header()
} }
@ViewBuilder
public var body: some View { public var body: some View {
switch content {
case .all:
switch layout { switch layout {
case .split: case .split:
splitBody splitBody
case .stacked: case .stacked:
stackedBody stackedBody
} }
case .calendar:
chartCard
case .metrics:
metricsBody
}
} }
// MARK: - Split (Mac / iPad) // MARK: - Split (Mac / iPad)
private var splitBody: some View { private var splitBody: some View {
HStack(alignment: .top, spacing: Spacing.md) { HStack(alignment: .top, spacing: splitSectionSpacing) {
chartCard chartCard
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
splitStatGrid splitStatGrid
@@ -81,126 +104,200 @@ public struct UsageStatsCluster<Header: View>: View {
} }
private var splitStatGrid: some View { private var splitStatGrid: some View {
VStack(spacing: Spacing.md) { VStack(spacing: splitItemSpacing) {
HStack(spacing: Spacing.md) { HStack(spacing: splitItemSpacing) {
UsageStatCard( splitCell(
title: SharedL10n.string("stat.words", language: language), title: SharedL10n.string("stat.words", language: language),
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
caption: SharedL10n.string("stat.transcribed", language: language), caption: SharedL10n.string("stat.transcribed", language: language),
systemImage: "text.alignleft", systemImage: "text.alignleft",
accent: true accent: true,
action: onOpenHistory
) )
UsageStatCard( splitCell(
title: SharedL10n.string("stat.dictationTime", language: language), title: SharedL10n.string("stat.dictationTime", language: language),
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
caption: SharedL10n.string("stat.cumulativeDuration", language: language), caption: SharedL10n.string("stat.cumulativeDuration", language: language),
systemImage: "waveform" systemImage: "waveform"
) )
} }
HStack(spacing: Spacing.md) { HStack(spacing: splitItemSpacing) {
UsageStatCard( splitCell(
title: SharedL10n.string("stat.translation", language: language), title: SharedL10n.string("stat.translation", language: language),
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
caption: SharedL10n.string("stat.cumulativeTranslation", language: language), caption: SharedL10n.string("stat.cumulativeTranslation", language: language),
systemImage: "character.bubble" systemImage: "character.bubble"
) )
UsageStatCard( splitCell(
title: SharedL10n.string("stat.dictionary", language: language), title: SharedL10n.string("stat.dictionary", language: language),
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
caption: SharedL10n.string("stat.customTerms", 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) // MARK: - Stacked (iPhone)
private var stackedBody: some View { private var stackedBody: some View {
VStack(spacing: compact ? Spacing.sm : Spacing.md) { VStack(spacing: CardLayoutMetrics.sectionSpacing) {
chartCard chartCard
compactStatGrid 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 @ViewBuilder
private var chartCard: some View { private var chartCard: some View {
if Header.self == EmptyView.self { if Header.self == EmptyView.self {
SevenDayUsageChart( MonthlyUsageCalendar(
points: points, points: points,
language: language, language: language,
chartMinHeight: compact ? 72 : 96, compact: compact
expands: layout == .split
) )
} else { } else {
UsageSurfaceCard(padding: Spacing.md) { UsageSurfaceCard(padding: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
header header
SevenDayUsageChart( MonthlyUsageCalendar(
points: points, points: points,
language: language, language: language,
chartMinHeight: compact ? 72 : 96, compact: compact,
embedsInCard: false, embedsInCard: false
expands: layout == .split
) )
} }
} }
} }
} }
/// 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 { private var compactStatGrid: some View {
VStack(spacing: 0) { VStack(spacing: CardLayoutMetrics.compactItemSpacing) {
HStack(spacing: 0) { HStack(spacing: CardLayoutMetrics.compactItemSpacing) {
compactCell( compactCell(
systemImage: "waveform", systemImage: "waveform.badge.microphone",
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language), value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
label: SharedL10n.string("stat.dictationTime", language: language) label: SharedL10n.string("stat.dictationTime", language: language)
) )
compactDivider
compactCell( compactCell(
systemImage: "text.alignleft", systemImage: "text.quote",
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language), value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
label: SharedL10n.string("stat.words", language: language) label: SharedL10n.string("stat.words", language: language),
action: onOpenHistory
) )
} }
Rectangle() .frame(maxHeight: .infinity)
.fill(palette.divider) HStack(spacing: CardLayoutMetrics.compactItemSpacing) {
.frame(height: 0.5)
HStack(spacing: 0) {
compactCell( compactCell(
systemImage: "character.bubble", systemImage: "translate",
value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language), value: UsageStatisticsStore.formatCount(translationCharacterCount, language: language),
label: SharedL10n.string("stat.translation", language: language) label: SharedL10n.string("stat.translation", language: language)
) )
compactDivider
compactCell( compactCell(
systemImage: "character.book.closed", systemImage: "books.vertical",
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language), 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 // HomeStatsCard 166pt
.frame(height: UsageStatsClusterLayout.compactGridHeight) .frame(height: UsageStatsClusterLayout.compactGridHeight)
.background(palette.surface) }
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay( @ViewBuilder
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) private func compactCell(
.stroke(palette.divider, lineWidth: 0.5) 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 var compactDivider: some View {
Rectangle()
.fill(palette.divider)
.frame(width: 0.5)
} }
private func compactCell(systemImage: String, value: String, label: String) -> some View { private func compactCellContent(
HStack(alignment: .top, spacing: Spacing.xs) { systemImage: String,
VStack(alignment: .leading, spacing: Spacing.xs) { 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) Text(value)
.font(.system(size: 24, weight: .semibold, design: .rounded)) .font(.system(size: 24, weight: .semibold, design: .rounded))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
@@ -214,14 +311,41 @@ public struct UsageStatsCluster<Header: View>: View {
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.85) .minimumScaleFactor(0.85)
} }
Spacer(minLength: Spacing.xs) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Image(systemName: systemImage)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(.top, 2)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md) .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, dictationDurationSeconds: TimeInterval,
translationCharacterCount: Int, translationCharacterCount: Int,
dictionaryTermCount: Int, dictionaryTermCount: Int,
compact: Bool = false compact: Bool = false,
content: UsageStatsClusterContent = .all,
onOpenHistory: (() -> Void)? = nil,
onOpenDictionary: (() -> Void)? = nil
) { ) {
self.init( self.init(
layout: layout, layout: layout,
@@ -245,6 +372,9 @@ extension UsageStatsCluster where Header == EmptyView {
translationCharacterCount: translationCharacterCount, translationCharacterCount: translationCharacterCount,
dictionaryTermCount: dictionaryTermCount, dictionaryTermCount: dictionaryTermCount,
compact: compact, compact: compact,
content: content,
onOpenHistory: onOpenHistory,
onOpenDictionary: onOpenDictionary,
header: { EmptyView() } header: { EmptyView() }
) )
} }
@@ -4,6 +4,7 @@
// Main-app-only storage for OSG account sessions and App Attest key state. // Main-app-only storage for OSG account sessions and App Attest key state.
import Foundation import Foundation
import OSLog
import Security import Security
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable { public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
@@ -42,6 +43,11 @@ public actor HostPrivateAccountKeychain:
AccountSessionVault, AccountSessionVault,
AppAttestKeyStateStoring, AppAttestKeyStateStoring,
OOBEInstallationIDStoring { OOBEInstallationIDStoring {
private static let logger = Logger(
subsystem: HostPrivateAccountKeychainDescriptor.hostBundleIdentifier,
category: "account"
)
private enum Account { private enum Account {
static let session = "account.session" static let session = "account.session"
static let appAttestKeyState = "integrity.app-attest-key-state" static let appAttestKeyState = "integrity.app-attest-key-state"
@@ -59,7 +65,16 @@ public actor HostPrivateAccountKeychain:
} }
public func loadSession() async throws -> AccountSession? { 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 { public func saveSession(_ session: AccountSession) async throws {
@@ -111,6 +126,9 @@ public actor HostPrivateAccountKeychain:
case errSecItemNotFound: case errSecItemNotFound:
return nil return nil
default: default:
Self.logger.error(
"keychain read failed account=\(account, privacy: .public) status=\(status, privacy: .public)"
)
throw AccountAPIError.secureStorage throw AccountAPIError.secureStorage
} }
} }
+2 -6
View File
@@ -88,13 +88,13 @@ struct DashboardView: View {
.frame(maxWidth: .infinity, alignment: .leading) .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 { private var statCluster: some View {
UsageStatsCluster( UsageStatsCluster(
layout: .split, layout: .split,
language: lang, language: lang,
points: stats.last7Days, points: stats.currentMonth,
dictationCharacterCount: stats.dictationCharacterCount, dictationCharacterCount: stats.dictationCharacterCount,
dictationDurationSeconds: stats.dictationDurationSeconds, dictationDurationSeconds: stats.dictationDurationSeconds,
translationCharacterCount: stats.translationCharacterCount, translationCharacterCount: stats.translationCharacterCount,
@@ -225,10 +225,6 @@ struct BottomDictationBar: View {
palette.surfaceElevated, palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
) )
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
} }
.menuStyle(.borderlessButton) .menuStyle(.borderlessButton)
.fixedSize() .fixedSize()
+1 -18
View File
@@ -133,7 +133,7 @@ private struct MacGlassSurface<S: Shape>: ViewModifier {
// Flat, shadowless surface fill. We deliberately avoid `glassEffect` // Flat, shadowless surface fill. We deliberately avoid `glassEffect`
// here: on macOS 26 Liquid Glass adds a raised drop shadow to every // here: on macOS 26 Liquid Glass adds a raised drop shadow to every
// card, which reads as visual noise for content containers. Hierarchy // 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 content
.background(palette.surface.opacity(fillOpacity), in: shape) .background(palette.surface.opacity(fillOpacity), in: shape)
} }
@@ -361,10 +361,6 @@ struct MacSettingsIconButton: View {
.foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary) .foregroundStyle(disabled ? palette.textTertiary : palette.textSecondary)
.frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight) .frame(width: MacMetrics.settingsControlHeight, height: MacMetrics.settingsControlHeight)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) .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) .buttonStyle(.plain)
.disabled(disabled) .disabled(disabled)
@@ -381,9 +377,6 @@ struct MacSettingsToolButton: View {
var fill: Color? var fill: Color?
/// Text color. Defaults to primary text (tertiary when disabled). /// Text color. Defaults to primary text (tertiary when disabled).
var foreground: Color? 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 var disabled: Bool = false
let action: () -> Void let action: () -> Void
@@ -396,11 +389,6 @@ struct MacSettingsToolButton: View {
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.frame(minHeight: 34) .frame(minHeight: 34)
.background(fill ?? palette.surfaceElevated, in: shape) .background(fill ?? palette.surfaceElevated, in: shape)
.overlay {
if showsBorder {
shape.stroke(palette.divider, lineWidth: 0.5)
}
}
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(disabled) .disabled(disabled)
@@ -485,7 +473,6 @@ extension MacPageHeader where Trailing == EmptyView {
/// Elevated surface used for stat tiles and the dictation canvas. /// Elevated surface used for stat tiles and the dictation canvas.
struct MacCard<Content: View>: View { struct MacCard<Content: View>: View {
@Environment(\.themePalette) private var palette
var padding: CGFloat = Spacing.md var padding: CGFloat = Spacing.md
var cornerRadius: CGFloat = Radius.medium var cornerRadius: CGFloat = Radius.medium
@ViewBuilder var content: () -> Content @ViewBuilder var content: () -> Content
@@ -496,10 +483,6 @@ struct MacCard<Content: View>: View {
content() content()
.padding(padding) .padding(padding)
.macGlassSurface(in: shape, fillOpacity: 1) .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 showEntryEditor = false
@State private var generatingAliasEntryIDs: Set<UUID> = [] @State private var generatingAliasEntryIDs: Set<UUID> = []
private let aliasGenerator = DictionaryAliasGenerator()
private var lang: AppUILanguage { viewModel.config.uiLanguage } private var lang: AppUILanguage { viewModel.config.uiLanguage }
private var entries: [PersonalDictionary.Entry] { private var entries: [PersonalDictionary.Entry] {
@@ -235,29 +233,22 @@ struct MacDictionaryView: View {
private func saveManualEntry(term: String) { private func saveManualEntry(term: String) {
let store = AppGroupStore(defaults: viewModel.defaults) let store = AppGroupStore(defaults: viewModel.defaults)
var dictionary = store.personalDictionary let service = PersonalDictionaryEntryService(
guard let saved = dictionary.upsertManual(term: term) else { return } store: store,
dictionary.version += 1 cloudPush: { dictionary in
store.setPersonalDictionary(dictionary) try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary)
}
)
guard let saved = service.saveEntry(term: term, source: .manual) else { return }
viewModel.refreshDictionaryFromCloud() viewModel.refreshDictionaryFromCloud()
generatingAliasEntryIDs.insert(saved.id) if saved.shouldGenerateAliases {
generatingAliasEntryIDs.insert(saved.entryID)
}
Task { Task {
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(dictionary) await service.finishSaving(saved)
let aliases = await aliasGenerator.generateAliases(for: saved.term) generatingAliasEntryIDs.remove(saved.entryID)
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)
viewModel.refreshDictionaryFromCloud() viewModel.refreshDictionaryFromCloud()
try? await MacICloudSyncBootstrap.dictionarySync.pushLocalIfEnabled(latest)
} }
} }
} }
@@ -377,8 +377,7 @@ struct MacLocalASRModelSettingsView: View {
MacSettingsToolButton( MacSettingsToolButton(
title: MacL10n.string("mac.localASR.delete", language: lang), title: MacL10n.string("mac.localASR.delete", language: lang),
fill: palette.danger.opacity(0.15), fill: palette.danger.opacity(0.15),
foreground: palette.danger, foreground: palette.danger
showsBorder: false
) { ) {
modelVM.deleteModel(model) modelVM.deleteModel(model)
} }
@@ -386,8 +385,7 @@ struct MacLocalASRModelSettingsView: View {
MacSettingsToolButton( MacSettingsToolButton(
title: MacL10n.string("mac.localASR.download", language: lang), title: MacL10n.string("mac.localASR.download", language: lang),
fill: palette.accent, fill: palette.accent,
foreground: .white, foreground: .white
showsBorder: false
) { ) {
modelVM.installModel(model) modelVM.installModel(model)
} }
+2 -7
View File
@@ -350,7 +350,6 @@ struct MacOnboardingView: View {
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface)) .background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
} }
private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View { private func permissionCard(isGranted: Bool, grantedText: String, neededText: String) -> some View {
@@ -368,7 +367,6 @@ struct MacOnboardingView: View {
.padding(Spacing.md) .padding(Spacing.md)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface)) .background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke((isGranted ? palette.accent : palette.warning).opacity(0.25), lineWidth: 1))
} }
private var enginePicker: some View { private var enginePicker: some View {
@@ -423,7 +421,6 @@ struct MacOnboardingView: View {
.padding(Spacing.md) .padding(Spacing.md)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(cardShape.fill(selected ? palette.accentMuted : palette.surface)) .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()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -461,7 +458,6 @@ struct MacOnboardingView: View {
.padding(Spacing.md) .padding(Spacing.md)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface)) .background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
} }
private var localModelPanel: some View { private var localModelPanel: some View {
@@ -513,7 +509,6 @@ struct MacOnboardingView: View {
.padding(Spacing.md) .padding(Spacing.md)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(cardShape.fill(palette.surface)) .background(cardShape.fill(palette.surface))
.overlay(cardShape.stroke(palette.divider, lineWidth: 0.5))
} }
// MARK: Progress dots // MARK: Progress dots
@@ -577,8 +572,8 @@ struct MacOnboardingView: View {
.padding(.horizontal, Spacing.lg) .padding(.horizontal, Spacing.lg)
.frame(minHeight: 44) .frame(minHeight: 44)
.background( .background(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous) palette.surfaceElevated,
.stroke(palette.dividerStrong, lineWidth: 0.5) in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
-10
View File
@@ -403,12 +403,6 @@ private struct MacPolishStyleCard: View {
isSelected ? palette.accentMuted : palette.surface, isSelected ? palette.accentMuted : palette.surface,
in: shape in: shape
) )
.overlay(
shape.stroke(
isSelected ? palette.accent : hoverBorder,
lineWidth: isSelected ? 1.5 : 0.5
)
)
.clipShape(shape) .clipShape(shape)
.scaleEffect(isHovering ? 1.01 : 1) .scaleEffect(isHovering ? 1.01 : 1)
.animation(Motion.quick, value: isHovering) .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). /// Read-only prompt viewer for built-in styles (mirrors iOS).
@@ -13,7 +13,7 @@ public struct CardPageContent<Content: View>: View {
private let content: Content private let content: Content
public init( public init(
spacing: CGFloat = Spacing.md, spacing: CGFloat = CardLayoutMetrics.sectionSpacing,
topPadding: CGFloat = Spacing.md, topPadding: CGFloat = Spacing.md,
bottomPadding: CGFloat = Spacing.md, bottomPadding: CGFloat = Spacing.md,
@ViewBuilder content: () -> Content @ViewBuilder content: () -> Content
@@ -95,12 +95,8 @@ public struct SurfaceCardModifier: ViewModifier {
in: shape in: shape
) )
// Clip child backgrounds as well as the card surface. Without // Clip child backgrounds as well as the card surface. Without
// this, a full-width child can visually square off a corner // this, a full-width child can visually square off a corner.
// even though the shared background and border use Radius.xl.
.clipShape(shape) .clipShape(shape)
.overlay(
shape.stroke(palette.divider, lineWidth: 0.5)
)
} else { } else {
content content
} }
+8 -8
View File
@@ -158,6 +158,14 @@ public enum Spacing {
public static let hero: CGFloat = 48 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 // MARK: - Corner radius scale
public enum Radius { public enum Radius {
@@ -227,10 +235,6 @@ private struct CardSurfaceModifier: ViewModifier {
content content
.padding(padding) .padding(padding)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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) .font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50) .frame(maxWidth: .infinity, minHeight: 50)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .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) .foregroundStyle(palette.textPrimary)
} }
} }
@@ -2,7 +2,7 @@
// OSGKeyboard · Shared // OSGKeyboard · Shared
// //
// Flat semantic surface used by home / dashboard stats on every platform. // 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 import SwiftUI
@@ -30,8 +30,5 @@ public struct UsageSurfaceCard<Content: View>: View {
.padding(padding) .padding(padding)
.background(palette.surface, in: shape) .background(palette.surface, in: shape)
.clipShape(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 uiLanguage: AppUILanguage
public var translationTargetLocaleId: String public var translationTargetLocaleId: String
public var handednessPreference: HandednessPreference public var handednessPreference: HandednessPreference
/// Decode-only compatibility field for cursor drag pads removed in 2.0.0.
public var cursorDragNavigationEnabled: Bool public var cursorDragNavigationEnabled: Bool
/// Typing-grid haptic strength (off / light / strong). /// Typing-grid haptic strength (off / light / strong).
public var keyboardHapticIntensity: KeyboardHapticIntensity public var keyboardHapticIntensity: KeyboardHapticIntensity
@@ -300,7 +301,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
), ),
cursorDragNavigationEnabled: { cursorDragNavigationEnabled: {
if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil { if defaults.object(forKey: Keys.cursorDragNavigationEnabled) == nil {
return true return false
} }
return defaults.bool(forKey: Keys.cursorDragNavigationEnabled) return defaults.bool(forKey: Keys.cursorDragNavigationEnabled)
}(), }(),
@@ -594,17 +595,7 @@ public struct AppGroupConfiguration: Sendable, Equatable {
return .empty return .empty
} }
do { do {
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data) return 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
} catch { } catch {
OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)") OSGLog.config.warning("personalDictionary decode failed: \(error.localizedDescription, privacy: .public)")
return .empty return .empty
@@ -39,12 +39,11 @@ public enum KeyboardChromeLayout {
public static let horizontalInset: CGFloat = 8 public static let horizontalInset: CGFloat = 8
/// Voice-surface content column cap. /// Voice-surface content column cap.
/// ///
/// The voice surface is a sparse cluster two cursor-drag pads flanking a /// The voice surface is a sparse control cluster over a transparent
/// fixed 121 pt mic over a transparent background, so filling an iPad's /// background, so filling an iPad's width buys no visual width and only
/// width buys no visual width; it only parks delete/return at the screen /// parks delete/return at the screen edges. The typing surface has the
/// edges and turns each drag pad into a ~450 pt runway. The typing surface /// opposite need (a key grid must fill the width to match the system
/// has the opposite need (a key grid must fill the width to match the /// keyboard), which is why it no longer shares this constant.
/// system keyboard), which is why it no longer shares this constant.
public static let voiceContentMaxWidth: CGFloat = 700 public static let voiceContentMaxWidth: CGFloat = 700
/// Width at or above which the typing surface switches to wide-iPad /// Width at or above which the typing surface switches to wide-iPad
@@ -8,7 +8,7 @@
// //
// Sources (mutually exclusive per entry): // Sources (mutually exclusive per entry):
// - `.manual` user typed it in by hand // - `.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 // - `.contacts` imported from the iOS Contacts framework
// - `.recentEdit` extracted from edits the user made to a // - `.recentEdit` extracted from edits the user made to a
// polished transcript before sending // polished transcript before sending
@@ -274,12 +274,28 @@ extension PersonalDictionary {
return entries.first { $0.term.lowercased() == key } 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 @discardableResult
public mutating func upsertManual( public mutating func upsertManual(
term: String, term: String,
existingID: UUID? = nil, existingID: UUID? = nil,
regenerateAliases: Bool = false 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? { ) -> Entry? {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil } guard !trimmed.isEmpty else { return nil }
@@ -292,7 +308,7 @@ extension PersonalDictionary {
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
entry.term = trimmed entry.term = trimmed
entry.category = category entry.category = category
entry.source = .manual entry.source = source
if termChanged || regenerateAliases { if termChanged || regenerateAliases {
entry.aliases = [] entry.aliases = []
} }
@@ -307,7 +323,7 @@ extension PersonalDictionary {
var entry = entries[idx] var entry = entries[idx]
entry.term = trimmed entry.term = trimmed
entry.category = category entry.category = category
entry.source = .manual entry.source = source
entry.updatedAt = Date() entry.updatedAt = Date()
entries[idx] = entry entries[idx] = entry
return entry return entry
@@ -318,7 +334,7 @@ extension PersonalDictionary {
term: trimmed, term: trimmed,
aliases: [], aliases: [],
category: category, category: category,
source: .manual, source: source,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
) )
+1 -13
View File
@@ -113,7 +113,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet { didSet {
guard !isApplyingConfiguration, localeId != configuration.localeId else { return } guard !isApplyingConfiguration, localeId != configuration.localeId else { return }
configuration.localeId = localeId configuration.localeId = localeId
persistConfiguration() persistConfiguration(postConfigChanged: true)
} }
} }
/// "local" on-device ASR + user's LLM polish (requires user API key). /// "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. /// Typing-grid haptic strength (off / light / strong). Default is light.
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity { @Published public var keyboardHapticIntensity: KeyboardHapticIntensity {
didSet { didSet {
@@ -445,7 +435,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
uiLanguage = configuration.uiLanguage uiLanguage = configuration.uiLanguage
translationTargetLocaleId = configuration.translationTargetLocaleId translationTargetLocaleId = configuration.translationTargetLocaleId
handednessPreference = configuration.handednessPreference handednessPreference = configuration.handednessPreference
cursorDragNavigationEnabled = configuration.cursorDragNavigationEnabled
keyboardHapticIntensity = configuration.keyboardHapticIntensity keyboardHapticIntensity = configuration.keyboardHapticIntensity
polishIntensity = configuration.polishIntensity polishIntensity = configuration.polishIntensity
aiResponseLength = configuration.aiResponseLength aiResponseLength = configuration.aiResponseLength
@@ -551,7 +540,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
uiLanguage = fresh.uiLanguage uiLanguage = fresh.uiLanguage
translationTargetLocaleId = fresh.translationTargetLocaleId translationTargetLocaleId = fresh.translationTargetLocaleId
handednessPreference = fresh.handednessPreference handednessPreference = fresh.handednessPreference
cursorDragNavigationEnabled = fresh.cursorDragNavigationEnabled
keyboardHapticIntensity = fresh.keyboardHapticIntensity keyboardHapticIntensity = fresh.keyboardHapticIntensity
polishIntensity = fresh.polishIntensity polishIntensity = fresh.polishIntensity
aiResponseLength = fresh.aiResponseLength aiResponseLength = fresh.aiResponseLength
@@ -70,6 +70,18 @@ public final class AIAgentSkillLayoutStore: ObservableObject {
mergedCatalog.filter { !layout.isEnabled($0.id) } 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? { public func userSkill(id: String) -> AIUserSkill? {
userCatalog.skill(id: id) userCatalog.skill(id: id)
} }
@@ -146,6 +146,17 @@ public enum AIClipboardSkillCatalog: Sendable {
empathyReplyID, empathyReplyID,
businessReplyID 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 extractTodosID = "extractTodos"
public static let extractTodosShortcutName = "OSGExtractTodos" public static let extractTodosShortcutName = "OSGExtractTodos"
public static let extractTodosResourceName = "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 translationEnabled: Bool { configuration.translationEnabled }
public var translationTargetLocaleId: String { configuration.translationTargetLocaleId } public var translationTargetLocaleId: String { configuration.translationTargetLocaleId }
public var handednessPreference: HandednessPreference { configuration.handednessPreference } public var handednessPreference: HandednessPreference { configuration.handednessPreference }
public var cursorDragNavigationEnabled: Bool { configuration.cursorDragNavigationEnabled }
public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity } public var keyboardHapticIntensity: KeyboardHapticIntensity { configuration.keyboardHapticIntensity }
public var polishIntensity: PolishIntensity { configuration.polishIntensity } public var polishIntensity: PolishIntensity { configuration.polishIntensity }
public var aiResponseLength: AIResponseLength { configuration.aiResponseLength } public var aiResponseLength: AIResponseLength { configuration.aiResponseLength }
@@ -140,6 +139,7 @@ public struct AppGroupStore: @unchecked Sendable {
public func setLocaleId(_ id: String) { public func setLocaleId(_ id: String) {
mutateConfiguration { $0.localeId = id } mutateConfiguration { $0.localeId = id }
AppGroupConfigDarwin.postConfigChanged()
} }
public func setEngineMode(_ mode: String) { public func setEngineMode(_ mode: String) {
@@ -180,11 +180,6 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged() AppGroupConfigDarwin.postConfigChanged()
} }
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
mutateConfiguration { $0.cursorDragNavigationEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) { public func setKeyboardHapticIntensity(_ intensity: KeyboardHapticIntensity) {
mutateConfiguration { $0.keyboardHapticIntensity = intensity } mutateConfiguration { $0.keyboardHapticIntensity = intensity }
AppGroupConfigDarwin.postConfigChanged() AppGroupConfigDarwin.postConfigChanged()
@@ -438,6 +433,19 @@ public struct AppGroupStore: @unchecked Sendable {
} }
// v6 persists canonical IDs for the consolidated reply, summary, // v6 persists canonical IDs for the consolidated reply, summary,
// and clarification skills. `sanitized` performs the mapping. // 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 { let additions = catalog.map(\.id).filter {
additionIDs.contains($0) && !decoded.enabledIDs.contains($0) 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 { private static func decodeUserSkillCatalog(from defaults: UserDefaults) -> AIUserSkillCatalog {
guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else { guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentUserSkillCatalog) else {
@@ -547,6 +555,28 @@ public struct AppGroupStore: @unchecked Sendable {
#endif #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()) { public func deletePersonalDictionaryEntry(id: UUID, at date: Date = Date()) {
mutateConfiguration { config in mutateConfiguration { config in
config.personalDictionary.entries.removeAll { $0.id == id } config.personalDictionary.entries.removeAll { $0.id == id }
@@ -31,8 +31,8 @@ public enum ClipboardSkillSemanticRanker {
) )
} }
/// Returns only skills supported by current semantic evidence. No matching /// Returns semantically relevant skills and always keeps the generic Reply
/// label produces no recommendation instead of a fixed fallback row. /// action available as a safe fallback for accepted clipboard text.
public static func recommended( public static func recommended(
skills: [AIClipboardSkill], skills: [AIClipboardSkill],
sourceText: String, sourceText: String,
@@ -42,22 +42,42 @@ public enum ClipboardSkillSemanticRanker {
preferredLanguages: [String] = Locale.preferredLanguages preferredLanguages: [String] = Locale.preferredLanguages
) -> [AIClipboardSkill] { ) -> [AIClipboardSkill] {
guard limit > 0 else { return [] } guard limit > 0 else { return [] }
let scores = relevanceScores( var scores = relevanceScores(
sourceText: sourceText, sourceText: sourceText,
analysis: analysis, analysis: analysis,
preferredLanguages: preferredLanguages 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 } let relevant = skills.filter { scores[$0.id, default: 0] > 0 }
var selected: [AIClipboardSkill] = [] var selected: [AIClipboardSkill] = []
var replyCount = 0 var specializedReplyCount = 0
for skill in sorted(relevant, scores: scores) { 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 { if skill.supportsReplyStyle {
guard replyCount < maximumReplyRecommendations else { continue } guard specializedReplyCount < maximumReplyRecommendations else { continue }
replyCount += 1 specializedReplyCount += 1
} }
selected.append(skill) selected.append(skill)
} }
if let genericReply,
selected.count < limit,
!selected.contains(where: { $0.id == genericReply.id }) {
selected.append(genericReply)
}
return selected return selected
} }
@@ -142,6 +142,12 @@ public final class KeyboardState: ObservableObject {
@Published public var returnKeyRole: ReturnKeyRole = .newline @Published public var returnKeyRole: ReturnKeyRole = .newline
/// Opt-in clipboard history capture (mirrored from App Group). /// Opt-in clipboard history capture (mirrored from App Group).
@Published public var clipboardHistoryEnabled: Bool = false @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). /// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false @Published public var clipboardCandidateBarEnabled: Bool = false
/// Skills-tab order for clipboard chips. Empty hint carousel. /// 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 enum PolishStyleLearningCorpusBuilder {
public static let requiredEffectiveCharacterCount = 5_000 public static let requiredEffectiveCharacterCount = 2_500
public static func build( public static func build(
from entries: [SpeechHistoryEntry] from entries: [SpeechHistoryEntry]
@@ -5,7 +5,10 @@
import Foundation import Foundation
public enum SettingsDeepLink: String, Sendable { public enum SettingsDeepLink: String, Sendable, Equatable {
case aiService
case speechRecognition
case textPolish
case clipboard case clipboard
private static let pendingKey = "settings.pendingDeepLink" private static let pendingKey = "settings.pendingDeepLink"
@@ -19,11 +19,11 @@ public final class UsageStatisticsStore: ObservableObject {
dictationCharacterCount + translationCharacterCount + aiCharacterCount dictationCharacterCount + translationCharacterCount + aiCharacterCount
} }
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by /// 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] = [:] @Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
/// How many days of daily buckets to retain on disk. Well beyond the 7-day /// How many days of daily buckets to retain on disk. This preserves several
/// chart window so a device that syncs in late still contributes recent days. /// complete monthly views when a device syncs late.
private static let dailyRetentionDays = 90 private static let dailyRetentionDays = 90
public let defaults: UserDefaults public let defaults: UserDefaults
@@ -121,13 +121,18 @@ public final class UsageStatisticsStore: ObservableObject {
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters 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 struct DailyUsagePoint: Identifiable, Equatable, Sendable {
public let date: Date public let date: Date
public let value: Int public let value: Int
public var id: Date { date } 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 /// The trailing 7 local days (oldest newest), zero-filled for days with no
@@ -151,6 +156,34 @@ public final class UsageStatisticsStore: ObservableObject {
return points 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 /// 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' /// 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 /// 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 public static let slotCount = 3
/// In-vocabulary words only yield to a much more common transposition / neighbor. /// In-vocabulary words only yield to a much more common transposition / neighbor.
public static let inVocabularyFrequencyGap = 250 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 private let lexicon: EnglishLexicon
@@ -150,6 +153,36 @@ public struct EnglishSuggestionEngine: Sendable {
return false 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 // MARK: - Board
private struct Board { private struct Board {
@@ -304,9 +337,11 @@ public struct EnglishSuggestionEngine: Sendable {
private func isProtectedToken(_ typed: String) -> Bool { private func isProtectedToken(_ typed: String) -> Bool {
if typed.count <= 2 { return true } if typed.count <= 2 { return true }
if typed.allSatisfy(\.isUppercase) { 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(where: \.isNumber) { return true }
if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true } if typed.contains("@") || typed.contains(".") || typed.contains("/") { return true }
if typed.contains("-") || typed.contains("_") { return true } if typed.contains("-") || typed.contains("_") { return true }
if typed.contains("'") || typed.contains("+") || typed.contains("#") { return true }
return false return false
} }
@@ -1,13 +1,14 @@
// RimeFrequentTermStore.swift // FrequentTermStore.swift
// OSGKeyboard · Shared // OSGKeyboard · Shared
// //
// Small App Group sidecar for Rime commits. Reading librime's LevelDB userdb // Small App Group sidecar for committed typing terms. Reading librime's
// while the keyboard owns it can race the engine, so the extension records // LevelDB userdb while the keyboard owns it can race the engine, so the
// eligible committed terms here and the host app ranks them for suggestions. // extension records eligible Chinese and English terms here and the host app
// ranks them for personal-dictionary suggestions.
import Foundation import Foundation
public struct RimeFrequentTerm: Codable, Equatable, Identifiable, Sendable { public struct FrequentTerm: Codable, Equatable, Identifiable, Sendable {
public var id: String { term.lowercased() } public var id: String { term.lowercased() }
public let term: String 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. /// PersonalDictionary until the user explicitly confirms a suggestion.
public final class RimeFrequentTermStore: @unchecked Sendable { public final class FrequentTermStore: @unchecked Sendable {
public static let defaultsKey = "rimeTyping.frequentTerms.v1" public static let defaultsKey = "typing.frequentTerms.v2"
public static let legacyRimeDefaultsKey = "rimeTyping.frequentTerms.v1"
public static let minimumSuggestionCount = 2 public static let minimumSuggestionCount = 2
public static let maximumTrackedTerms = 256 public static let maximumTrackedTerms = 256
@@ -52,7 +54,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
let key = term.lowercased() let key = term.lowercased()
if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) { if let index = terms.firstIndex(where: { $0.term.lowercased() == key }) {
let existing = terms[index] let existing = terms[index]
terms[index] = RimeFrequentTerm( terms[index] = FrequentTerm(
term: term, term: term,
commitCount: min(10_000, existing.commitCount + 1), commitCount: min(10_000, existing.commitCount + 1),
firstSeenAt: existing.firstSeenAt, firstSeenAt: existing.firstSeenAt,
@@ -60,7 +62,7 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
) )
} else { } else {
terms.append( terms.append(
RimeFrequentTerm( FrequentTerm(
term: term, term: term,
commitCount: 1, commitCount: 1,
firstSeenAt: date, firstSeenAt: date,
@@ -78,11 +80,11 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
saveLocked(Array(terms.prefix(Self.maximumTrackedTerms))) 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( public func suggestions(
excludingPersonalTerms personalTerms: Set<String>, excludingPersonalTerms personalTerms: Set<String>,
limit: Int = 5 limit: Int = 5
) -> [RimeFrequentTerm] { ) -> [FrequentTerm] {
guard limit > 0 else { return [] } guard limit > 0 else { return [] }
let excluded = Set(personalTerms.map { $0.lowercased() }) let excluded = Set(personalTerms.map { $0.lowercased() })
@@ -111,13 +113,14 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
public func clear() { public func clear() {
lock.lock() lock.lock()
defaults.removeObject(forKey: Self.defaultsKey) defaults.removeObject(forKey: Self.defaultsKey)
defaults.removeObject(forKey: Self.legacyRimeDefaultsKey)
lock.unlock() lock.unlock()
} }
static func normalizedCandidate(from text: String) -> String? { static func normalizedCandidate(from text: String) -> String? {
let term = text.trimmingCharacters(in: .whitespacesAndNewlines) let term = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard (2...12).contains(term.count), guard (2...32).contains(term.count),
!commonTerms.contains(term), !commonTerms.contains(term.lowercased()),
!term.unicodeScalars.contains(where: { !term.unicodeScalars.contains(where: {
CharacterSet.whitespacesAndNewlines.contains($0) CharacterSet.whitespacesAndNewlines.contains($0)
}) else { }) else {
@@ -131,7 +134,8 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
semanticCharacterCount += 1 semanticCharacterCount += 1
continue 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 { guard allowedSeparators.contains(Character(String(scalar))) else {
return nil return nil
} }
@@ -139,21 +143,40 @@ public final class RimeFrequentTermStore: @unchecked Sendable {
return semanticCharacterCount >= 2 ? term : nil return semanticCharacterCount >= 2 ? term : nil
} }
private func loadLocked() -> [RimeFrequentTerm] { private func loadLocked() -> [FrequentTerm] {
guard let data = defaults.data(forKey: Self.defaultsKey), if let data = defaults.data(forKey: Self.defaultsKey),
let terms = try? JSONDecoder().decode([RimeFrequentTerm].self, from: data) else { let terms = try? JSONDecoder().decode([FrequentTerm].self, from: data) {
return []
}
return terms return terms
} }
private func saveLocked(_ terms: [RimeFrequentTerm]) { guard let legacyData = defaults.data(forKey: Self.legacyRimeDefaultsKey),
let legacyTerms = try? JSONDecoder().decode([FrequentTerm].self, from: legacyData) else {
return []
}
// 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: [FrequentTerm]) {
guard let data = try? JSONEncoder().encode(terms) else { return } guard let data = try? JSONEncoder().encode(terms) else { return }
defaults.set(data, forKey: Self.defaultsKey) defaults.set(data, forKey: Self.defaultsKey)
} }
/// Avoid recommending ubiquitous conversational glue as a personal term. /// Avoid recommending ubiquitous conversational glue as a personal term.
private static let commonTerms: Set<String> = [ 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 import Foundation
public enum TypingHabitStore { 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. /// Does not touch PersonalDictionary / osg_personal.
public static func clearAll( public static func clearAll(
englishStore: EnglishLearningStore = EnglishLearningStore(), englishStore: EnglishLearningStore = EnglishLearningStore(),
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore() frequentTermStore: FrequentTermStore = FrequentTermStore()
) async throws { ) async throws {
englishStore.clear() englishStore.clear()
rimeFrequentTermStore.clear() frequentTermStore.clear()
try await RimeResourceInstaller.shared.clearUserDictionary() try await RimeResourceInstaller.shared.clearUserDictionary()
} }
} }
@@ -15,8 +15,16 @@ public final class TypingSessionController: ObservableObject {
@Published public private(set) var capsLock: Bool = false @Published public private(set) var capsLock: Bool = false
/// Finger is down on Shift (iOS: hold for continuous uppercase; release ends). /// 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 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 engineReady: Bool = false
@Published public private(set) var isPreparingEngine: Bool = false
@Published public private(set) var schema: TypingInputSchema @Published public private(set) var schema: TypingInputSchema
/// Chinese-only: key grid replaced by a same-height candidate grid. /// Chinese-only: key grid replaced by a same-height candidate grid.
@Published public private(set) var isCandidatePanelExpanded: Bool = false @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. /// Chinese composition is also skipped so passwords never enter Rime userdb.
@Published public var suggestionsEnabled: Bool = true { @Published public var suggestionsEnabled: Bool = true {
didSet { didSet {
guard oldValue, !suggestionsEnabled else { return } guard oldValue != suggestionsEnabled else { return }
abandonChineseComposition() if !suggestionsEnabled {
clearDocumentScopedState()
}
} }
} }
/// `UITextChecker` completions / guesses. Empty in unit tests. /// `UITextChecker` completions / guesses. Empty in unit tests.
@@ -54,12 +64,16 @@ public final class TypingSessionController: ObservableObject {
public let layout: TypingLayoutProviding public let layout: TypingLayoutProviding
private let engineFactory: @MainActor () -> RimeEngineBridging private let engineFactory: @MainActor () -> RimeEngineBridging
private let englishFactory: @MainActor () -> EnglishSuggestionEngine private let englishFactory: @MainActor () -> EnglishSuggestionEngine
private let hostHeavyProvider: @MainActor () -> Bool
private let learningStore: EnglishLearningStore private let learningStore: EnglishLearningStore
private let rimeFrequentTermStore: RimeFrequentTermStore private let frequentTermStore: FrequentTermStore
private var engineStorage: RimeEngineBridging? private var engineStorage: RimeEngineBridging?
private var englishStorage: EnglishSuggestionEngine? private var englishStorage: EnglishSuggestionEngine?
private var prepared = false private var prepared = false
private var prepareTask: Task<Void, Never>? private var prepareTask: Task<Void, Never>?
private var engineEpoch: UInt64 = 0
private var documentPresentationCounter: UInt64 = 0
private var activeDocumentPresentationID: UInt64?
private var engine: RimeEngineBridging { private var engine: RimeEngineBridging {
if let engineStorage { return engineStorage } if let engineStorage { return engineStorage }
@@ -99,13 +113,17 @@ public final class TypingSessionController: ObservableObject {
layout: TypingLayoutProviding = StandardTypingLayout(), layout: TypingLayoutProviding = StandardTypingLayout(),
englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil, englishEngine: (@MainActor () -> EnglishSuggestionEngine)? = nil,
learningStore: EnglishLearningStore = EnglishLearningStore(), learningStore: EnglishLearningStore = EnglishLearningStore(),
rimeFrequentTermStore: RimeFrequentTermStore = RimeFrequentTermStore() frequentTermStore: FrequentTermStore = FrequentTermStore(),
hostHeavyProvider: @escaping @MainActor () -> Bool = {
FlowSessionBridge.isHostHeavy()
}
) { ) {
self.engineFactory = engine ?? { LibrimeEngine() } self.engineFactory = engine ?? { LibrimeEngine() }
self.layout = layout self.layout = layout
self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() } self.englishFactory = englishEngine ?? { EnglishSuggestionEngine() }
self.hostHeavyProvider = hostHeavyProvider
self.learningStore = learningStore self.learningStore = learningStore
self.rimeFrequentTermStore = rimeFrequentTermStore self.frequentTermStore = frequentTermStore
// Avoid constructing librime until the first Chinese keystroke / prepare. // Avoid constructing librime until the first Chinese keystroke / prepare.
schema = TypingInputConfiguration.shared.schema schema = TypingInputConfiguration.shared.schema
} }
@@ -134,7 +152,7 @@ public final class TypingSessionController: ObservableObject {
} }
public func enterTypingMode() { public func enterTypingMode() {
let hostHeavy = FlowSessionBridge.isHostHeavy() let hostHeavy = hostHeavyProvider()
KeyboardExtensionMemoryTelemetry.record( KeyboardExtensionMemoryTelemetry.record(
"typing.enter.begin", "typing.enter.begin",
details: "language=\(language.rawValue) schema=\(schema.rawValue) " 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") OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
} }
syncAutocapitalization() syncAutocapitalization()
isPreparingEngine = !prepared
if hostHeavy { if hostHeavy {
KeyboardExtensionMemoryTelemetry.record( KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred", "typing.rimePrepare.deferred",
@@ -172,24 +191,28 @@ public final class TypingSessionController: ObservableObject {
) )
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot") OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
prepareTask?.cancel() prepareTask?.cancel()
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in prepareTask = Task { [weak self] in
for _ in 0..<40 { while self?.hostHeavyProvider() == true {
try? await Task.sleep(nanoseconds: 250_000_000) try? await Task.sleep(nanoseconds: 250_000_000)
guard let self, !Task.isCancelled else { return } guard !Task.isCancelled else { return }
if !FlowSessionBridge.isHostHeavy() { }
await self.prepareIfNeeded() 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
} }
} }
self?.prepareTask = nil
}
return return
} }
if prepareTask == nil, !prepared { if prepareTask == nil, !prepared {
engineEpoch &+= 1
let epoch = engineEpoch
prepareTask = Task { [weak self] in prepareTask = Task { [weak self] in
await self?.prepareIfNeeded() await self?.prepareIfNeeded(engineEpoch: epoch)
self?.prepareTask = nil guard let self, self.engineEpoch == epoch else { return }
self.prepareTask = nil
} }
} }
} }
@@ -200,17 +223,14 @@ public final class TypingSessionController: ObservableObject {
details: "language=\(language.rawValue)" details: "language=\(language.rawValue)"
) )
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot") OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
engineEpoch &+= 1
prepareTask?.cancel() prepareTask?.cancel()
prepareTask = nil prepareTask = nil
endDocumentPresentation()
engineStorage?.teardown() engineStorage?.teardown()
prepared = false prepared = false
engineReady = false engineReady = false
composition = .empty isPreparingEngine = false
isCandidatePanelExpanded = false
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
// Drop English lexicon pages when leaving typing (jetsam recovery). // Drop English lexicon pages when leaving typing (jetsam recovery).
EnglishLexicon.shared.unload() EnglishLexicon.shared.unload()
englishStorage = nil 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() { public func toggleCandidatePanelExpanded() {
guard canExpandCandidatePanel else { guard canExpandCandidatePanel else {
isCandidatePanelExpanded = false isCandidatePanelExpanded = false
@@ -248,7 +294,7 @@ public final class TypingSessionController: ObservableObject {
if !raw.isEmpty { output = .insert(raw) } if !raw.isEmpty { output = .insert(raw) }
} }
language = newLanguage language = newLanguage
engine.setLanguage(newLanguage) engineStorage?.setLanguage(newLanguage)
page = .letters page = .letters
isCandidatePanelExpanded = false isCandidatePanelExpanded = false
clearPeriodShortcut() clearPeriodShortcut()
@@ -258,7 +304,9 @@ public final class TypingSessionController: ObservableObject {
englishEngine.prepare() englishEngine.prepare()
refreshEnglishSuggestions() refreshEnglishSuggestions()
syncAutocapitalization() syncAutocapitalization()
if activeDocumentPresentationID != nil {
synchronizeEnglishDocumentContext(caretMoved: true) synchronizeEnglishDocumentContext(caretMoved: true)
}
} else { } else {
clearEnglishWordState(keepPrevious: false) clearEnglishWordState(keepPrevious: false)
EnglishLexicon.shared.unload() EnglishLexicon.shared.unload()
@@ -267,6 +315,14 @@ public final class TypingSessionController: ObservableObject {
return output 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 /// Flushes raw preedit, selects the next built-in scheme, and returns the
/// text that the caller should insert before switching. /// text that the caller should insert before switching.
public func cycleSchema() -> TypingOutput { public func cycleSchema() -> TypingOutput {
@@ -371,7 +427,7 @@ public final class TypingSessionController: ObservableObject {
composition = engine.composition composition = engine.composition
syncCandidatePanelVisibility() syncCandidatePanelVisibility()
clearOneShotShiftIfNeeded() clearOneShotShiftIfNeeded()
recordRimeCommit(committed) recordChineseCommit(committed)
return committed.isEmpty ? .none : .insert(committed) return committed.isEmpty ? .none : .insert(committed)
} }
@@ -387,7 +443,7 @@ public final class TypingSessionController: ObservableObject {
let text = engine.processSpace() ?? " " let text = engine.processSpace() ?? " "
composition = engine.composition composition = engine.composition
syncCandidatePanelVisibility() syncCandidatePanelVisibility()
recordRimeCommit(text) recordChineseCommit(text)
return .insert(text) return .insert(text)
} }
@@ -403,7 +459,7 @@ public final class TypingSessionController: ObservableObject {
let text = engine.processReturn() ?? "\n" let text = engine.processReturn() ?? "\n"
composition = engine.composition composition = engine.composition
syncCandidatePanelVisibility() syncCandidatePanelVisibility()
recordRimeCommit(text) recordChineseCommit(text)
return .insert(text) return .insert(text)
} }
@@ -423,13 +479,28 @@ public final class TypingSessionController: ObservableObject {
// Selecting always collapses; follow-up composition may reopen . // Selecting always collapses; follow-up composition may reopen .
isCandidatePanelExpanded = false isCandidatePanelExpanded = false
syncCandidatePanelVisibility() syncCandidatePanelVisibility()
recordRimeCommit(text) recordChineseCommit(text)
return text.isEmpty ? .none : .insert(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 } 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. /// Drop in-flight pinyin so secure fields cannot commit into userdb.
@@ -575,6 +646,7 @@ public final class TypingSessionController: ObservableObject {
UIKitEnglishSystemLexicon.learnWord(word) UIKitEnglishSystemLexicon.learnWord(word)
#endif #endif
} }
recordEnglishCommit(word)
refreshEnglishSuggestions(afterCommittedWord: word) refreshEnglishSuggestions(afterCommittedWord: word)
return suffix.isEmpty ? .none : .insert(suffix) return suffix.isEmpty ? .none : .insert(suffix)
} }
@@ -597,6 +669,7 @@ public final class TypingSessionController: ObservableObject {
#if canImport(UIKit) #if canImport(UIKit)
UIKitEnglishSystemLexicon.learnWord(pending.original) UIKitEnglishSystemLexicon.learnWord(pending.original)
#endif #endif
recordEnglishCommit(pending.original)
refreshEnglishSuggestions(afterCommittedWord: pending.original) refreshEnglishSuggestions(afterCommittedWord: pending.original)
return .replace(deleteCount: deleteCount, with: pending.original + " ") return .replace(deleteCount: deleteCount, with: pending.original + " ")
} }
@@ -609,6 +682,7 @@ public final class TypingSessionController: ObservableObject {
} else { } else {
learningStore.recordAcceptance(of: chosen) learningStore.recordAcceptance(of: chosen)
} }
recordEnglishCommit(chosen)
if !englishCurrentWord.isEmpty { if !englishCurrentWord.isEmpty {
let deleteCount = englishCurrentWord.count let deleteCount = englishCurrentWord.count
@@ -627,6 +701,19 @@ public final class TypingSessionController: ObservableObject {
return .insert(chosen + " ") 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 { private func commitEnglishWordIfNeededBeforeNonLetter() -> TypingOutput {
guard language == .english, !englishCurrentWord.isEmpty else { return .none } guard language == .english, !englishCurrentWord.isEmpty else { return .none }
return commitEnglishWord(suffix: "") return commitEnglishWord(suffix: "")
@@ -696,8 +783,15 @@ public final class TypingSessionController: ObservableObject {
/// Rebuilds English suggestion state from the real caret context. /// Rebuilds English suggestion state from the real caret context.
/// At the document end, callbacks keep the local shadow when a host /// At the document end, callbacks keep the local shadow when a host
/// briefly reports the immediately preceding edit (common in Notes). /// 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 } guard language == .english else { return }
if let presentationID,
activeDocumentPresentationID != presentationID {
return
}
if caretMoved { if caretMoved {
clearPeriodShortcut() clearPeriodShortcut()
} }
@@ -843,6 +937,16 @@ public final class TypingSessionController: ObservableObject {
pendingAutocorrection = nil pendingAutocorrection = nil
} }
private func clearDocumentScopedState() {
engineStorage?.clearComposition()
composition = .empty
isCandidatePanelExpanded = false
page = .letters
resetShiftState()
clearEnglishWordState(keepPrevious: false)
clearPeriodShortcut()
}
private func synchronizeEnglishWordState( private func synchronizeEnglishWordState(
precedingText: String, precedingText: String,
followingText: 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 } guard !prepared else { return }
if FlowSessionBridge.isHostHeavy() { guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
if hostHeavyProvider() {
KeyboardExtensionMemoryTelemetry.record( KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.deferred", "typing.rimePrepare.deferred",
details: "hostHeavy=1" details: "hostHeavy=1"
) )
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot") 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( KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.begin", "typing.rimePrepare.begin",
@@ -963,9 +1071,11 @@ public final class TypingSessionController: ObservableObject {
) )
do { do {
try await engine.prepare() try await engine.prepare()
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
engine.setLanguage(language) engine.setLanguage(language)
prepared = true prepared = true
engineReady = engine.isReady engineReady = engine.isReady
isPreparingEngine = false
schema = engine.schema schema = engine.schema
lastError = nil lastError = nil
lastErrorNeedsHostDeployment = false lastErrorNeedsHostDeployment = false
@@ -981,10 +1091,12 @@ public final class TypingSessionController: ObservableObject {
category: "boot" category: "boot"
) )
} catch { } catch {
guard engineEpoch == expectedEpoch, !Task.isCancelled else { return }
lastError = error.localizedDescription lastError = error.localizedDescription
lastErrorNeedsHostDeployment = lastErrorNeedsHostDeployment =
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false (error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
engineReady = false engineReady = false
isPreparingEngine = false
KeyboardExtensionMemoryTelemetry.record( KeyboardExtensionMemoryTelemetry.record(
"typing.rimePrepare.failed", "typing.rimePrepare.failed",
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))" 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 prepareTask == nil else { return }
guard RimeResourceInstaller.isReady else { return } guard RimeResourceInstaller.isReady else { return }
OSGDiag.log("rime.prepare retry after deployment", category: "boot") OSGDiag.log("rime.prepare retry after deployment", category: "boot")
engineEpoch &+= 1
let epoch = engineEpoch
isPreparingEngine = true
prepareTask = Task { [weak self] in prepareTask = Task { [weak self] in
await self?.prepareIfNeeded() await self?.prepareIfNeeded(engineEpoch: epoch)
self?.prepareTask = nil 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 */ /* v0.3.0: Personal dictionary sources */
"dict.source.manual" = "Manual"; "dict.source.manual" = "Manual";
"dict.source.history" = "Auto-learned"; "dict.source.history" = "Recommended hotword";
"dict.source.contacts" = "From Contacts"; "dict.source.contacts" = "From Contacts";
"dict.source.recentEdit" = "From recent edit"; "dict.source.recentEdit" = "From recent edit";
@@ -202,7 +202,7 @@
"mac.styles.learn.generating" = "Generating…"; "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.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.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.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.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."; "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.title" = "Last 7 days";
"stat.weekChart.caption" = "Dictation chars"; "stat.weekChart.caption" = "Dictation chars";
"stat.weekChart.empty" = "No dictation yet this week"; "stat.weekChart.empty" = "No dictation yet this week";
"stat.monthChart.caption" = "Monthly dictation chars";
"mac.status.chipReady" = "Ready"; "mac.status.chipReady" = "Ready";
"mac.status.chipProcessing" = "Processing"; "mac.status.chipProcessing" = "Processing";
"mac.overlay.listening" = "Listening"; "mac.overlay.listening" = "Listening";
@@ -152,7 +152,7 @@
/* v0.3.0: 词库来源 */ /* v0.3.0: 词库来源 */
"dict.source.manual" = "手动添加"; "dict.source.manual" = "手动添加";
"dict.source.history" = "自动学习"; "dict.source.history" = "推荐热词";
"dict.source.contacts" = "来自通讯录"; "dict.source.contacts" = "来自通讯录";
"dict.source.recentEdit" = "来自最近编辑"; "dict.source.recentEdit" = "来自最近编辑";
@@ -201,7 +201,7 @@
"mac.styles.learn.generating" = "生成中…"; "mac.styles.learn.generating" = "生成中…";
"mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。"; "mac.styles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
"mac.styles.learn.limit" = "请先删除一个自定义风格,再生成新风格。"; "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.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
"mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。"; "mac.styles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
"mac.styles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。"; "mac.styles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
@@ -233,6 +233,7 @@
"stat.weekChart.title" = "近 7 天"; "stat.weekChart.title" = "近 7 天";
"stat.weekChart.caption" = "听写字数"; "stat.weekChart.caption" = "听写字数";
"stat.weekChart.empty" = "本周还没有听写记录"; "stat.weekChart.empty" = "本周还没有听写记录";
"stat.monthChart.caption" = "本月听写字数";
"mac.status.chipReady" = "就绪"; "mac.status.chipReady" = "就绪";
"mac.status.chipProcessing" = "处理中"; "mac.status.chipProcessing" = "处理中";
"mac.overlay.listening" = "聆听中"; "mac.overlay.listening" = "聆听中";
+102 -7
View File
@@ -21,6 +21,37 @@ final class AIAgentSkillLayoutTests: XCTestCase {
XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty) 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() { func testEmptyEnabledListIsPreserved() {
let defaults = makeDefaults() let defaults = makeDefaults()
let store = AppGroupStore(defaults: defaults) let store = AppGroupStore(defaults: defaults)
@@ -30,7 +61,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
XCTAssertEqual(store.agentSkillLayout.enabledIDs, []) XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
} }
func testLegacyLayoutAppendsNewDefaultSkillsWithoutRestoringDisabledLegacySkill() throws { func testLegacyLayoutInstallsCurrentRequiredDefaultSkills() throws {
let defaults = makeDefaults() let defaults = makeDefaults()
let legacy = AIAgentSkillLayout( let legacy = AIAgentSkillLayout(
enabledIDs: [ enabledIDs: [
@@ -50,7 +81,7 @@ final class AIAgentSkillLayoutTests: XCTestCase {
Array(migrated.enabledIDs.prefix(2)), Array(migrated.enabledIDs.prefix(2)),
[AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.translateID] [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.acceptInvitationID))
XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID)) XCTAssertTrue(migrated.enabledIDs.contains(AIClipboardSkillCatalog.extractEventsID))
} }
@@ -77,10 +108,16 @@ final class AIAgentSkillLayoutTests: XCTestCase {
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.playfulReplyID, AIClipboardSkillCatalog.playfulReplyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.openLinkID, AIClipboardSkillCatalog.openLinkID,
AIClipboardSkillCatalog.summarizeWebPageID, AIClipboardSkillCatalog.summarizeWebPageID,
AIClipboardSkillCatalog.callPhoneID, 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, migrated.enabledIDs,
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.openLinkID, AIClipboardSkillCatalog.openLinkID,
AIClipboardSkillCatalog.summarizeWebPageID, AIClipboardSkillCatalog.summarizeWebPageID,
AIClipboardSkillCatalog.callPhoneID, 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, migrated.enabledIDs,
[ [
AIClipboardSkillCatalog.replyID, AIClipboardSkillCatalog.replyID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.callPhoneID, 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.replyID,
AIClipboardSkillCatalog.summarizeID, AIClipboardSkillCatalog.summarizeID,
AIClipboardSkillCatalog.clarifyRequestID AIClipboardSkillCatalog.clarifyRequestID,
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.empathyReplyID,
AIClipboardSkillCatalog.organizeListID
] ]
) )
XCTAssertEqual( XCTAssertEqual(
defaults.integer( defaults.integer(
forKey: AppGroupConfiguration.Keys.agentSkillDefaultsMigrationVersion 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() { func testCannotEnableExportSkillBeforeShortcutConfirmation() {
let store = AIAgentSkillLayoutStore(defaults: makeDefaults()) let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
store.disable(AIClipboardSkillCatalog.extractTodosID) store.disable(AIClipboardSkillCatalog.extractTodosID)
@@ -34,6 +34,33 @@ final class AIHistoryAndUsageTests: XCTestCase {
XCTAssertEqual(store.totalInputCharacterCount, 4) 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 { func testLegacyHistoryEntryDefaultsToDictationSource() throws {
let payload: [String: Any] = [ let payload: [String: Any] = [
"id": UUID().uuidString, "id": UUID().uuidString,
@@ -194,6 +194,51 @@ final class AccountAPIClientTests: XCTestCase {
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/refresh") 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 { func testConcurrentUnauthorizedRequestsMergeRefreshRotation() async throws {
let old = makeAccountSession() let old = makeAccountSession()
let replacement = makeAccountSession( let replacement = makeAccountSession(
@@ -60,6 +60,7 @@ final class AccountCenterViewModelTests: XCTestCase {
) )
await coordinator.restoreIfNeeded() await coordinator.restoreIfNeeded()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
let handled = coordinator.handleIncomingURL( let handled = coordinator.handleIncomingURL(
URL(string: "https://osglab.com/i/\(validCode)")! URL(string: "https://osglab.com/i/\(validCode)")!
) )
@@ -123,6 +124,83 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(restoreCount, 1) 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 @MainActor
func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() async { func testFreshAccountSnapshotDoesNotReloadWhenEnteringAccountPages() async {
let account = AccountSession( let account = AccountSession(
@@ -529,12 +607,15 @@ private final class AccountSessionEventSourceStub: AccountSessionEventSourcing {
private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing { private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing {
private let restored: AccountSession? private let restored: AccountSession?
private let signedInAccount: AccountSession?
private let centerSnapshot: AccountCenterSnapshot? private let centerSnapshot: AccountCenterSnapshot?
private let refreshedSnapshot: AccountCenterSnapshot? private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool private let shouldFailRedemption: Bool
private let shouldFailAccountRefresh: Bool private let shouldFailAccountRefresh: Bool
private let signOutDelayNanoseconds: UInt64 private let signOutDelayNanoseconds: UInt64
private let signInDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64 private let accountLoadDelayNanoseconds: UInt64
private var remainingRestoreFailures: Int
private var redeemed: [String] = [] private var redeemed: [String] = []
private var centerLoadCount = 0 private var centerLoadCount = 0
private var logoutCount = 0 private var logoutCount = 0
@@ -545,30 +626,43 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
init( init(
restoredSession: AccountSession?, restoredSession: AccountSession?,
signInSession: AccountSession? = nil,
snapshot: AccountCenterSnapshot? = nil, snapshot: AccountCenterSnapshot? = nil,
refreshedSnapshot: AccountCenterSnapshot? = nil, refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false, shouldFailRedemption: Bool = false,
shouldFailAccountRefresh: Bool = false, shouldFailAccountRefresh: Bool = false,
signOutDelayNanoseconds: UInt64 = 0, signOutDelayNanoseconds: UInt64 = 0,
signInDelayNanoseconds: UInt64 = 0,
restoreFailureCount: Int = 0,
accountLoadDelayNanoseconds: UInt64 = 0 accountLoadDelayNanoseconds: UInt64 = 0
) { ) {
restored = restoredSession restored = restoredSession
signedInAccount = signInSession ?? restoredSession
centerSnapshot = snapshot centerSnapshot = snapshot
self.refreshedSnapshot = refreshedSnapshot self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption self.shouldFailRedemption = shouldFailRedemption
self.shouldFailAccountRefresh = shouldFailAccountRefresh self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.signOutDelayNanoseconds = signOutDelayNanoseconds self.signOutDelayNanoseconds = signOutDelayNanoseconds
self.signInDelayNanoseconds = signInDelayNanoseconds
remainingRestoreFailures = restoreFailureCount
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
} }
func restoreSession() async throws -> AccountSession? { func restoreSession() async throws -> AccountSession? {
sessionRestoreCount += 1 sessionRestoreCount += 1
if remainingRestoreFailures > 0 {
remainingRestoreFailures -= 1
throw AccountServiceSpyError.failed
}
return restored return restored
} }
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession { func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
guard let restored else { throw AccountIntegrationError.unavailable } if signInDelayNanoseconds > 0 {
return restored try await Task.sleep(nanoseconds: signInDelayNanoseconds)
}
guard let signedInAccount else { throw AccountIntegrationError.unavailable }
return signedInAccount
} }
func signOut() async throws { func signOut() async throws {
@@ -30,7 +30,8 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId) XCTAssertEqual(config.translationTargetLocaleId, TranslationLanguageCatalog.offLocaleId)
XCTAssertFalse(config.translationEnabled) XCTAssertFalse(config.translationEnabled)
XCTAssertEqual(config.handednessPreference, .left) 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.keyboardHapticIntensity, .light)
XCTAssertEqual(config.polishIntensity, .light) XCTAssertEqual(config.polishIntensity, .light)
XCTAssertEqual(config.aiResponseLength, .medium) XCTAssertEqual(config.aiResponseLength, .medium)
@@ -93,6 +94,24 @@ final class AppGroupConfigurationTests: XCTestCase {
XCTAssertEqual(loaded.flowInactivityDuration, .threeHours) 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() { func testFieldLevelSavePreservesNewerUnrelatedProcessChange() {
let defaults = makeDefaults() let defaults = makeDefaults()
let baseline = AppGroupConfiguration.load(fromAvailable: defaults) let baseline = AppGroupConfiguration.load(fromAvailable: defaults)
@@ -45,7 +45,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
preferredLanguages: ["zh-Hans"] preferredLanguages: ["zh-Hans"]
).map(\.id) ).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.translateID]) XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.translateID,
AIClipboardSkillCatalog.replyID
]
)
} }
func testInvitationWithDatePromotesCalendarAndBothReplyChoices() { func testInvitationWithDatePromotesCalendarAndBothReplyChoices() {
@@ -91,7 +97,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.openLinkID, AIClipboardSkillCatalog.openLinkID,
AIClipboardSkillCatalog.summarizeWebPageID AIClipboardSkillCatalog.summarizeWebPageID,
AIClipboardSkillCatalog.replyID
] ]
) )
} }
@@ -111,7 +118,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.openLinkID, 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.openLinkID))
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeWebPageID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeWebPageID))
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testSinglePhoneNumberOffersCallAndCreateContact() { func testSinglePhoneNumberOffersCallAndCreateContact() {
@@ -149,7 +158,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.callPhoneID, AIClipboardSkillCatalog.callPhoneID,
AIClipboardSkillCatalog.createContactID AIClipboardSkillCatalog.createContactID,
AIClipboardSkillCatalog.replyID
] ]
) )
} }
@@ -169,7 +179,8 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
recommendations, recommendations,
[ [
AIClipboardSkillCatalog.callPhoneID, 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.callPhoneID))
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.createContactID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.createContactID))
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testTaskListPromotesTodoAndOrganizationSkills() { func testTaskListPromotesTodoAndOrganizationSkills() {
@@ -259,7 +271,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(ranked, baseline) XCTAssertEqual(ranked, baseline)
} }
func testRecommendationsSelectOnlySemanticallyRelevantSkills() { func testRecommendationsAddReplyToSemanticallyRelevantSkills() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "北京市朝阳区望京街 10 号,到了给我电话。", sourceText: "北京市朝阳区望京街 10 号,到了给我电话。",
@@ -268,12 +280,18 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
limit: 5 limit: 5
).map(\.id) ).map(\.id)
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.navigateID]) XCTAssertEqual(
recommendations,
[
AIClipboardSkillCatalog.navigateID,
AIClipboardSkillCatalog.replyID
]
)
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.summarizeID))
XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID)) XCTAssertFalse(recommendations.contains(AIClipboardSkillCatalog.translateID))
} }
func testRecommendationsStayEmptyWhenNoSemanticLabelMatches() { func testRecommendationsFallBackToReplyWhenNoSemanticLabelMatches() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "知道了", sourceText: "知道了",
@@ -282,7 +300,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
limit: 5 limit: 5
).map(\.id) ).map(\.id)
XCTAssertTrue(recommendations.isEmpty) XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testNegativeReplyableMessageDoesNotOfferPlayfulReply() { func testNegativeReplyableMessageDoesNotOfferPlayfulReply() {
@@ -300,7 +318,7 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID]) XCTAssertEqual(recommendations, [AIClipboardSkillCatalog.replyID])
} }
func testInvitationKeepsTwoSpecificRepliesWithoutGenericReplies() { func testInvitationKeepsSpecificRepliesAndGenericReply() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "今晚七点老地方吃饭,你能来吗?", sourceText: "今晚七点老地方吃饭,你能来吗?",
@@ -319,12 +337,13 @@ final class ClipboardSkillSemanticRankerTests: XCTestCase {
[ [
AIClipboardSkillCatalog.extractEventsID, AIClipboardSkillCatalog.extractEventsID,
AIClipboardSkillCatalog.acceptInvitationID, AIClipboardSkillCatalog.acceptInvitationID,
AIClipboardSkillCatalog.declineInvitationID AIClipboardSkillCatalog.declineInvitationID,
AIClipboardSkillCatalog.replyID
] ]
) )
} }
func testRecommendationsNeverContainMoreThanTwoReplySkills() { func testForeignQuestionKeepsReplyAndOneSpecializedFollowUp() {
let recommendations = ClipboardSkillSemanticRanker.recommended( let recommendations = ClipboardSkillSemanticRanker.recommended(
skills: AIClipboardSkillCatalog.catalog, skills: AIClipboardSkillCatalog.catalog,
sourceText: "Could you send the final proposal by Friday?", 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( private func descriptor(
for lifecycle: FlowPiPLifecycleState for lifecycle: FlowPiPLifecycleState
) -> FlowHomePiPStatusDescriptor { ) -> FlowHomePiPStatusDescriptor {
@@ -1,15 +1,15 @@
// RimeFrequentTermStoreTests.swift // FrequentTermStoreTests.swift
// OSGKeyboardTests // OSGKeyboardTests
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest import XCTest
final class RimeFrequentTermStoreTests: XCTestCase { final class FrequentTermStoreTests: XCTestCase {
private var defaults: UserDefaults! private var defaults: UserDefaults!
private var suiteName: String! private var suiteName: String!
override func setUpWithError() throws { override func setUpWithError() throws {
suiteName = "RimeFrequentTermStoreTests.\(UUID().uuidString)" suiteName = "FrequentTermStoreTests.\(UUID().uuidString)"
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
} }
@@ -19,8 +19,8 @@ final class RimeFrequentTermStoreTests: XCTestCase {
suiteName = nil suiteName = nil
} }
func testRepeatedRimeCommitBecomesSuggestion() { func testRepeatedCommitBecomesSuggestion() {
let store = RimeFrequentTermStore(defaults: defaults) let store = FrequentTermStore(defaults: defaults)
store.recordCommittedText("少数派") store.recordCommittedText("少数派")
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty) XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty)
@@ -32,7 +32,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
} }
func testSuggestionsExcludeExistingDictionaryTermsAndCommonWords() { func testSuggestionsExcludeExistingDictionaryTermsAndCommonWords() {
let store = RimeFrequentTermStore(defaults: defaults) let store = FrequentTermStore(defaults: defaults)
for _ in 0..<4 { for _ in 0..<4 {
store.recordCommittedText("我们") store.recordCommittedText("我们")
store.recordCommittedText("飞书文档") store.recordCommittedText("飞书文档")
@@ -47,7 +47,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
} }
func testSuggestionsRankFrequencyBeforeRecency() { func testSuggestionsRankFrequencyBeforeRecency() {
let store = RimeFrequentTermStore(defaults: defaults) let store = FrequentTermStore(defaults: defaults)
let earlier = Date(timeIntervalSince1970: 100) let earlier = Date(timeIntervalSince1970: 100)
let later = Date(timeIntervalSince1970: 200) let later = Date(timeIntervalSince1970: 200)
for _ in 0..<3 { for _ in 0..<3 {
@@ -64,7 +64,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
} }
func testPunctuationAndSingleCharactersAreIgnored() { func testPunctuationAndSingleCharactersAreIgnored() {
let store = RimeFrequentTermStore(defaults: defaults) let store = FrequentTermStore(defaults: defaults)
for _ in 0..<3 { for _ in 0..<3 {
store.recordCommittedText("") store.recordCommittedText("")
store.recordCommittedText("你好!") store.recordCommittedText("你好!")
@@ -75,7 +75,7 @@ final class RimeFrequentTermStoreTests: XCTestCase {
} }
func testClearRemovesLearnedSuggestions() { func testClearRemovesLearnedSuggestions() {
let store = RimeFrequentTermStore(defaults: defaults) let store = FrequentTermStore(defaults: defaults)
store.recordCommittedText("少数派") store.recordCommittedText("少数派")
store.recordCommittedText("少数派") store.recordCommittedText("少数派")
XCTAssertFalse(store.suggestions(excludingPersonalTerms: []).isEmpty) XCTAssertFalse(store.suggestions(excludingPersonalTerms: []).isEmpty)
@@ -84,4 +84,48 @@ final class RimeFrequentTermStoreTests: XCTestCase {
XCTAssertTrue(store.suggestions(excludingPersonalTerms: []).isEmpty) 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) XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym)
} }
func testPersonalDictionaryMigratesLegacyHistorySource() { func testPersonalDictionaryPreservesRecommendedHistorySource() {
let legacy = PersonalDictionary(entries: [ let dictionary = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history) 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") defaults.set(data, forKey: "config.personalDictionary.v1")
let loaded = store.personalDictionary let loaded = store.personalDictionary
XCTAssertEqual(loaded.entries.first?.source, .manual) XCTAssertEqual(loaded.entries.first?.source, .history)
XCTAssertEqual(loaded.entries.first?.term, "Kubernetes") 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 // PolishStyleLearningServiceTests.swift
// OSGKeyboard · Tests // 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. // generation receives both paired examples and the prompts that produced them.
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
@@ -54,7 +54,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertEqual(corpus.examples.count, 1) XCTAssertEqual(corpus.examples.count, 1)
XCTAssertEqual(corpus.examples.first?.polishStyleID, "builtin.light") XCTAssertEqual(corpus.examples.first?.polishStyleID, "builtin.light")
XCTAssertEqual(corpus.effectiveCharacterCount, 7) XCTAssertEqual(corpus.effectiveCharacterCount, 7)
XCTAssertEqual(corpus.remainingCharacterCount, 4_993) XCTAssertEqual(corpus.remainingCharacterCount, 2_493)
XCTAssertFalse(corpus.isReady) XCTAssertFalse(corpus.isReady)
} }
@@ -74,8 +74,8 @@ final class PolishStyleLearningServiceTests: XCTestCase {
XCTAssertEqual(corpus.effectiveCharacterCount, 7) XCTAssertEqual(corpus.effectiveCharacterCount, 7)
} }
func testCorpusUnlocksAtFiveThousandEffectiveCharacters() { func testCorpusUnlocksAtTwoThousandFiveHundredEffectiveCharacters() {
let text = String(repeating: "", count: 5_000) let text = String(repeating: "", count: 2_500)
let corpus = PolishStyleLearningCorpusBuilder.build( let corpus = PolishStyleLearningCorpusBuilder.build(
from: [ from: [
SpeechHistoryEntry( SpeechHistoryEntry(
@@ -86,7 +86,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
] ]
) )
XCTAssertEqual(corpus.effectiveCharacterCount, 5_000) XCTAssertEqual(corpus.effectiveCharacterCount, 2_500)
XCTAssertEqual(corpus.remainingCharacterCount, 0) XCTAssertEqual(corpus.remainingCharacterCount, 0)
XCTAssertTrue(corpus.isReady) XCTAssertTrue(corpus.isReady)
} }
@@ -108,7 +108,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
store.setPolishStyleCatalog(catalog) store.setPolishStyleCatalog(catalog)
store.setActivePolishStyleId(activeStyle.id) store.setActivePolishStyleId(activeStyle.id)
let source = String(repeating: "测试语料", count: 1_250) let source = String(repeating: "测试语料", count: 625)
let corpus = PolishStyleLearningCorpus( let corpus = PolishStyleLearningCorpus(
examples: [ examples: [
PolishStyleLearningExample( PolishStyleLearningExample(
@@ -120,7 +120,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
createdAt: Date() createdAt: Date()
) )
], ],
effectiveCharacterCount: 5_000 effectiveCharacterCount: 2_500
) )
let client = StyleLearningCapturingClient( let client = StyleLearningCapturingClient(
response: ##"{"name":"","prompt":"# \n自然直接\n# \n不改变原意\n# \n输入 ","allowsAddedEmoji":false}"## response: ##"{"name":"","prompt":"# \n自然直接\n# \n不改变原意\n# \n输入 ","allowsAddedEmoji":false}"##
@@ -156,7 +156,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
createdAt: Date() createdAt: Date()
) )
], ],
effectiveCharacterCount: 5_000 effectiveCharacterCount: 2_500
) )
let service = PolishStyleLearningService( let service = PolishStyleLearningService(
store: store, store: store,
@@ -169,7 +169,7 @@ final class PolishStyleLearningServiceTests: XCTestCase {
} catch let error as PolishStyleLearningError { } catch let error as PolishStyleLearningError {
XCTAssertEqual( XCTAssertEqual(
error, error,
.insufficientCorpus(required: 5_000, actual: 5) .insufficientCorpus(required: 2_500, actual: 5)
) )
} catch { } catch {
XCTFail("Unexpected error: \(error)") XCTFail("Unexpected error: \(error)")
@@ -35,6 +35,19 @@ final class AccountCenterUITests: XCTestCase {
XCTAssertTrue(element("account.purchase.3000tks", in: app).exists) 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() { func testVerifiedCreditPurchaseShowsSuccessState() {
let app = launch() let app = launch()
let purchase = element("account.purchase.500tks", in: app) let purchase = element("account.purchase.500tks", in: app)
@@ -59,6 +72,12 @@ final class AccountCenterUITests: XCTestCase {
element("account.purchaseHistory.list", in: app) element("account.purchaseHistory.list", in: app)
.waitForExistence(timeout: 5) .waitForExistence(timeout: 5)
) )
let purchaseRow = element(
"account.purchaseHistory.row.2000000000199",
in: app
)
XCTAssertTrue(purchaseRow.waitForExistence(timeout: 5))
XCTAssertGreaterThanOrEqual(purchaseRow.frame.height, 64)
} }
func testManagedCreditsRequireFirstUseCloudConsent() { 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/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple)
![Platform](https://img.shields.io/badge/macOS-15%2B-555?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) ![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) ![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) [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 and skills
- Clipboard history is off by default; when enabled, it keeps up to 15 plain-text items in the device-local App Group - 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 - 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 - 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 ## 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/iOS%20%2F%20iPadOS-26%2B-0078D4?logo=apple)
![Platform](https://img.shields.io/badge/macOS-15%2B-555?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) ![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) ![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) [官网](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 - 剪贴板历史默认关闭,开启后最多保存 15 条纯文本,仅存于本机 App Group
- 复制后短时间显示回复、总结、翻译等快捷技能 - 复制后由本地语义分析从完整技能目录中推荐最多 5 个相关操作
- 内置待办、日程与备忘录可通过 Apple 快捷指令导出;地图导航直接打开高德、百度或 Apple 地图 - 内置待办、日程与备忘录可通过 Apple 快捷指令导出;地图导航直接打开高德、百度或 Apple 地图
- 可创建自定义技能:名称、SF Symbol、提示词和可选 iCloud 快捷指令 - 可创建自定义技能:名称、SF Symbol、提示词和可选 iCloud 快捷指令
- 最多将 8 个技能放到键盘,支持长按拖动排序 - 技能安装数量不限;技能页可安装、卸载和排序,键盘按当前剪贴板内容动态展示相关技能
## 三种服务路径 ## 三种服务路径
+15 -2
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import re
import sys import sys
from pathlib import Path from pathlib import Path
@@ -25,6 +26,12 @@ TEST_ROOTS = [
] ]
SHARED_TEST_ROOT = ROOT / "Tests" SHARED_TEST_ROOT = ROOT / "Tests"
SHARED_TEST_TARGETS = ["OSGKeyboardTests", "OSGKeyboardMacTests"] 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: def load_manifest() -> dict:
@@ -77,6 +84,11 @@ def collect_tests(manifest: dict, group_ids: list[str]) -> list[str]:
return tests 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]: def discover_on_disk_test_classes() -> dict[str, Path]:
"""Map Target/ClassName -> swift path for *Tests.swift files (exclude helpers).""" """Map Target/ClassName -> swift path for *Tests.swift files (exclude helpers)."""
found: dict[str, Path] = {} found: dict[str, Path] = {}
@@ -86,13 +98,14 @@ def discover_on_disk_test_classes() -> dict[str, Path]:
target = root.name target = root.name
for path in sorted(root.glob("*Tests.swift")): for path in sorted(root.glob("*Tests.swift")):
# Skip non-XCTest helpers that happen to end with Tests (none today). # Skip non-XCTest helpers that happen to end with Tests (none today).
class_name = path.stem for class_name in discover_xctest_classes(path):
test_id = f"{target}/{class_name}" test_id = f"{target}/{class_name}"
found[test_id] = path found[test_id] = path
if SHARED_TEST_ROOT.is_dir(): if SHARED_TEST_ROOT.is_dir():
for path in sorted(SHARED_TEST_ROOT.glob("*Tests.swift")): for path in sorted(SHARED_TEST_ROOT.glob("*Tests.swift")):
for class_name in discover_xctest_classes(path):
for target in SHARED_TEST_TARGETS: for target in SHARED_TEST_TARGETS:
test_id = f"{target}/{path.stem}" test_id = f"{target}/{class_name}"
found[test_id] = path found[test_id] = path
return found return found
+4 -1
View File
@@ -63,6 +63,7 @@
"tests": [ "tests": [
"OSGKeyboardTests/SettingsCloudSyncTests", "OSGKeyboardTests/SettingsCloudSyncTests",
"OSGKeyboardTests/PersonalDictionaryCloudSyncTests", "OSGKeyboardTests/PersonalDictionaryCloudSyncTests",
"OSGKeyboardTests/PersonalDictionaryEntryServiceTests",
"OSGKeyboardTests/PersonalDictionaryMergeTests", "OSGKeyboardTests/PersonalDictionaryMergeTests",
"OSGKeyboardTests/SpeechHistoryCloudSyncTests", "OSGKeyboardTests/SpeechHistoryCloudSyncTests",
"OSGKeyboardTests/SpeechHistoryDayDeletionTests", "OSGKeyboardTests/SpeechHistoryDayDeletionTests",
@@ -178,12 +179,14 @@
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests", "OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests", "OSGKeyboardTests/AIHintPoolTests",
"OSGKeyboardTests/AIHintKeywordExtractorTests", "OSGKeyboardTests/AIHintKeywordExtractorTests",
"OSGKeyboardTests/AIClipboardSkillTests",
"OSGKeyboardTests/WebPageContentServiceTests", "OSGKeyboardTests/WebPageContentServiceTests",
"OSGKeyboardTests/AIAgentSkillLayoutTests", "OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/AIUserSkillTests", "OSGKeyboardTests/AIUserSkillTests",
"OSGKeyboardTests/AIUserSkillStoreTests", "OSGKeyboardTests/AIUserSkillStoreTests",
"OSGKeyboardTests/AIEventExtractionTests", "OSGKeyboardTests/AIEventExtractionTests",
"OSGKeyboardTests/AIAddressExtractionTests", "OSGKeyboardTests/AIAddressExtractionTests",
"OSGKeyboardTests/AIMapNavigationTests",
"OSGKeyboardTests/AINoteExportTests", "OSGKeyboardTests/AINoteExportTests",
"OSGKeyboardTests/ClipboardSemanticAnalyzerTests", "OSGKeyboardTests/ClipboardSemanticAnalyzerTests",
"OSGKeyboardTests/ClipboardSkillSemanticRankerTests", "OSGKeyboardTests/ClipboardSkillSemanticRankerTests",
@@ -191,7 +194,7 @@
"OSGKeyboardTests/ClipboardHistoryStoreTests", "OSGKeyboardTests/ClipboardHistoryStoreTests",
"OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests", "OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests",
"OSGKeyboardTests/OfficialSkillCatalogTests", "OSGKeyboardTests/OfficialSkillCatalogTests",
"OSGKeyboardTests/RimeFrequentTermStoreTests" "OSGKeyboardTests/FrequentTermStoreTests"
] ]
}, },
"host_misc": { "host_misc": {

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