Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fee4d8492 | |||
| 498f407585 | |||
| 0f9280bd00 | |||
| aa1059135f | |||
| bb62bbcba1 | |||
| 94a3ddb053 | |||
| 805b01a68e | |||
| fb97ec2937 | |||
| 46f6d818b8 | |||
| 13214e6601 |
@@ -63,6 +63,16 @@ 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
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
xcodebuild \
|
||||||
|
-project OSGKeyboard.xcodeproj \
|
||||||
|
-scheme OSGKeyboardUITests \
|
||||||
|
-destination 'platform=iOS Simulator,name=iPhone 17' \
|
||||||
|
-only-testing:OSGKeyboardUITests/AccountCenterUITests \
|
||||||
|
-onlyUsePackageVersionsFromResolvedFile \
|
||||||
|
test
|
||||||
- name: Build Release
|
- name: Build Release
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|||||||
+34
-1
@@ -11,6 +11,14 @@ disabled_rules:
|
|||||||
- cyclomatic_complexity
|
- cyclomatic_complexity
|
||||||
- force_cast
|
- force_cast
|
||||||
- force_try
|
- force_try
|
||||||
|
# Existing code intentionally treats invalid UTF-8 as a recoverable parse
|
||||||
|
# failure rather than replacing bytes with U+FFFD.
|
||||||
|
- non_optional_string_data_conversion
|
||||||
|
# Guard-style loop bodies are clearer than compound `where` clauses in the
|
||||||
|
# text-processing state machines used throughout the project.
|
||||||
|
- for_where
|
||||||
|
# SwiftUI initializers commonly expose two or more closure parameters.
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
|
||||||
opt_in_rules:
|
opt_in_rules:
|
||||||
- empty_count
|
- empty_count
|
||||||
@@ -18,7 +26,32 @@ opt_in_rules:
|
|||||||
- explicit_init
|
- explicit_init
|
||||||
- prefer_self_type_over_type_of_self
|
- prefer_self_type_over_type_of_self
|
||||||
- sorted_imports
|
- sorted_imports
|
||||||
- redundant_optional_initialization
|
- implicit_optional_initialization
|
||||||
|
|
||||||
|
# Algorithmic code uses conventional one- and two-letter local coordinates
|
||||||
|
# (`x`, `y`, `dx`, `i`) while public names remain descriptive.
|
||||||
|
identifier_name:
|
||||||
|
min_length:
|
||||||
|
warning: 1
|
||||||
|
max_length:
|
||||||
|
warning: 50
|
||||||
|
|
||||||
|
# These limits flag genuinely unwieldy declarations without rejecting common
|
||||||
|
# SwiftUI geometry tuples, dependency initializers, or nested Codable payloads.
|
||||||
|
type_name:
|
||||||
|
max_length:
|
||||||
|
warning: 50
|
||||||
|
large_tuple:
|
||||||
|
warning: 7
|
||||||
|
error: 9
|
||||||
|
function_parameter_count:
|
||||||
|
warning: 12
|
||||||
|
error: 15
|
||||||
|
nesting:
|
||||||
|
type_level:
|
||||||
|
warning: 3
|
||||||
|
function_level:
|
||||||
|
warning: 5
|
||||||
|
|
||||||
included:
|
included:
|
||||||
- OSGKeyboard
|
- OSGKeyboard
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
|||||||
|
|
||||||
### Version format
|
### Version format
|
||||||
|
|
||||||
The current source-of-truth version is **1.8.0 (build 72)**. Releases use stable SemVer:
|
The current source-of-truth version is **2.0.0 (build 83)**. Releases use stable SemVer:
|
||||||
|
|
||||||
| Field | File | Rule |
|
| Field | File | Rule |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
|
|||||||
+51
-90
@@ -7,8 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。
|
||||||
|
- **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。
|
||||||
|
- **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-08-19
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Optional OSG account**: add Sign in with Apple, managed credits, App Store credit packs, referrals, profile controls, and in-app account deletion while keeping local dictation and user-owned provider keys independent. / **可选 OSG 账号**:新增 Apple 登录、托管积分、App Store 积分包、邀请、资料管理与 App 内账号注销,同时保持本地听写和用户自备 API Key 独立可用。
|
||||||
|
- **Safe AI insertion**: AI answers insert automatically only while the original input field and cursor context still match; otherwise the keyboard retains the answer for explicit insertion or discard. / **安全 AI 上屏**:仅当原输入框和光标上下文仍一致时自动插入 AI 回答;上下文变化时保留回答,由用户明确插入或丢弃。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Unified assistant keyboard**: merge Voice and AI into one Assistant tab with tap-to-dictate, hold-to-ask-AI, a liquid-glass capsule microphone, contextual hotwords, one-row paged clipboard skills, and shared Send / undo / edit actions; delete, space, undo, and edit remain available while clipboard skills are visible. / **统一助手键盘**:将语音与 AI 合并为一个助手入口,支持轻点听写、长按问 AI、液态玻璃胶囊麦克风、情境热词、单行分页剪贴板技能,以及共用的发送 / 撤销 / 编辑操作;剪贴板技能出现时仍保留删除、空格、撤销与编辑按钮。
|
||||||
|
- **Adaptive field action**: the assistant action now follows the focused field’s current content and Return semantics, refreshes immediately after keyboard-generated edits, and shows Send, Search, Go, Done, Next, newline, and other matching icons. / **自适应输入框动作**:助手动作现根据焦点输入框的当前内容与 Return 语义显示发送、搜索、前往、完成、下一步、换行等对应图标,并在键盘主动编辑后立即刷新。
|
||||||
|
- **Clipboard setup guidance**: replace the Skills permission wall with a compact next-step card that enables history inline, verifies paste access, hides completed steps, and shortens Clipboard settings copy. / **剪贴板设置指引**:技能页权限墙改为紧凑的下一步卡片,可直接开启历史、验证粘贴访问并隐藏已完成步骤,同时精简剪贴板设置文案。
|
||||||
|
- **AI output language**: apply the global translation target to AI answers while letting an explicit language request override it and preserving source-language structured export data. / **AI 输出语言**:AI 回答遵循全局翻译目标,但明确的语言请求优先,结构化导出数据保留源语言。
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Cursor drag pads**: remove blank-area cursor sliding and its Settings toggle; legacy synced values remain decode-compatible. / **光标拖动区**:移除空白区域滑动光标及其设置开关;旧版同步值仍保持解码兼容。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Account confirmation anchors**: sign-out and account-deletion confirmations now open from their selected action rows instead of the profile summary card. / **账号确认弹窗锚点**:退出登录与注销账号确认弹窗现在从对应操作行弹出,不再错误指向资料卡。
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-14
|
## [1.8.0] - 2026-08-14
|
||||||
|
|
||||||
|
> **Release highlights**: See the concise [1.8.0 release notes](docs/RELEASE_NOTES_1.8.0.md) for the key changes since 1.6.6. / **版本亮点**:请参阅精简的 [1.8.0 更新说明](docs/RELEASE_NOTES_1.8.0.md),了解自 1.6.6 以来的关键变化。
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **English QuickType bar**: while typing a word, three equal slots show the verbatim text (quoted when unknown), the unique Space correction, and a completion. The bar stays empty before typing and between committed words. / **英文 QuickType 栏**:输入单词时,三个等宽格显示原文(生词带引号)、空格会采用的唯一纠错和补全;尚未输入及单词提交后保持空白。
|
- **English QuickType bar**: while typing a word, three equal slots show the verbatim text (quoted when unknown), the unique Space correction, and a completion. The bar stays empty before typing and between committed words. / **英文 QuickType 栏**:输入单词时,三个等宽格显示原文(生词带引号)、空格会采用的唯一纠错和补全;尚未输入及单词提交后保持空白。
|
||||||
- **System English lexicon**: typing uses `UITextChecker` completions/guesses and `requestSupplementaryLexicon` contact names / text replacements. / **系统英文词库**:打字使用 `UITextChecker` 补全/猜测,以及 `requestSupplementaryLexicon` 的通讯录名与文本替换。
|
- **System English lexicon**: typing uses `UITextChecker` completions/guesses and `requestSupplementaryLexicon` contact names / text replacements. / **系统英文词库**:打字使用 `UITextChecker` 补全/猜测,以及 `requestSupplementaryLexicon` 的通讯录名与文本替换。
|
||||||
@@ -26,6 +57,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
|
- **Save to Notes skill**: the Skills tab opens a ready-made companion Shortcut named `OSGSaveToNotes` on the system Add page (one new Apple Note with an explicit title and body). After you tap Add, copying text and tapping Notes asks the LLM for a short title from the time and content; the body stays the original clipboard. / **存入备忘录技能**:技能页会打开已做好的配套捷径 `OSGSaveToNotes` 的系统添加页(新建一条带标题和正文的苹果备忘录)。点添加后,复制文字并点「备忘录」会按时间和内容生成短标题;正文保持剪贴板原文。
|
||||||
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
|
||||||
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, optional iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Without a link, the model result is reviewed and inserted directly; with a link, it keeps the Shortcut export flow. Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、可选 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。不填链接时,模型结果经确认后直接插入;填写链接时继续走捷径导出流程。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, optional iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Without a link, the model result is reviewed and inserted directly; with a link, it keeps the Shortcut export flow. Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、可选 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。不填链接时,模型结果经确认后直接插入;填写链接时继续走捷径导出流程。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
|
||||||
|
- **AI keyboard mode**: ask the configured LLM by voice, watch answers stream into the keyboard, then review, Insert, and optionally Send; supported providers can search the web with automatic fallback. / **AI 键盘模式**:通过语音向已配置模型提问,回答会实时显示在键盘中,确认后可插入并按需发送;支持的服务商可联网搜索,失败时自动回退。
|
||||||
|
- **AI idle suggestions and response length**: the empty AI surface rotates tappable evergreen and current-topic prompts; Settings → AI Agent chooses Short, Medium, or Detailed answers. / **AI 空闲建议与回复篇幅**:AI 空状态轮播可点按的常用问题与热点建议;设置 → AI Agent 可选择简短、中等或详细回答。
|
||||||
|
- **AI setup guidance**: Home and the keyboard microphone line show a clear prompt when the user-owned AI key is missing; dictation can still insert the raw on-device transcript. / **AI 配置引导**:未配置用户自备 AI Key 时,首页与键盘麦克风上方会明确提示;听写仍可插入设备端原始识别结果。
|
||||||
|
- **Clipboard history and suggestion strip**: optionally keep the latest 15 plain-text copies on device, open them from the keyboard top bar, and show the newest copy above Voice, Typing, and AI for one-tap insertion. / **剪贴板历史与建议条**:可选在本机保存最近 15 条纯文本,从键盘顶栏打开历史,并在语音、打字与 AI 界面上方显示最新复制内容以便一键插入。
|
||||||
|
- **Edit last input**: hold the microphone after an OSGKeyboard insertion, describe the change, compare the original and edited text, then replace or append without exposing unrelated field content. / **编辑上次输入**:OSGKeyboard 输入后长按麦克风口述修改要求,对比原文与结果后可替换或追加,且不会读取无关输入框内容。
|
||||||
|
- **iPad keyboard workspace**: voice and typing surfaces fill the available width in portrait and landscape; the bottom row adds comma, period, and the native globe key, while typing gains undo, redo, copy, and cut. / **iPad 键盘工作区**:语音与打字界面在横竖屏均铺满可用宽度;底行加入逗号、句号与原生地球键,打字顶栏提供撤销、重做、复制和剪切。
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Privacy and third-party disclosures**: align the bundled and web privacy policies with AI Agent skills, Shortcuts/maps handoff, OSGKeyboard web endpoints, Keychain/iCloud behavior, clipboard retention, local typing learning, and Mac model downloads; expand the iOS/macOS license catalog with complete upstream notices. / **隐私与第三方披露**:统一 App 内与网页隐私政策,补充 AI Agent 技能、快捷指令/地图交接、OSGKeyboard 网页端点、Keychain/iCloud、剪贴板保留、本地输入学习与 Mac 模型下载,并为 iOS/macOS 许可目录补齐上游声明。
|
- **Privacy and third-party disclosures**: align the bundled and web privacy policies with AI Agent skills, Shortcuts/maps handoff, OSGKeyboard web endpoints, Keychain/iCloud behavior, clipboard retention, local typing learning, and Mac model downloads; expand the iOS/macOS license catalog with complete upstream notices. / **隐私与第三方披露**:统一 App 内与网页隐私政策,补充 AI Agent 技能、快捷指令/地图交接、OSGKeyboard 网页端点、Keychain/iCloud、剪贴板保留、本地输入学习与 Mac 模型下载,并为 iOS/macOS 许可目录补齐上游声明。
|
||||||
@@ -34,15 +71,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **English lexicon mmap**: the 40k-word English table loads only in English and stays file-mapped; Chinese typing no longer pulls it in on keyboard appear. / **英文词表 mmap**:4 万词英文表仅在英文加载且走文件映射;中文打字不再在唤起键盘时一并灌入。
|
- **English lexicon mmap**: the 40k-word English table loads only in English and stays file-mapped; Chinese typing no longer pulls it in on keyboard appear. / **英文词表 mmap**:4 万词英文表仅在英文加载且走文件映射;中文打字不再在唤起键盘时一并灌入。
|
||||||
- **English autocorrect conservatism**: Title Case / short / ALL CAPS tokens are not replaced, except same-length transpositions (`Teh` → `The`). Machine-applied corrections no longer boost the replacement; rejecting them learns the original. / **英文自动更正更克制**:Title Case / 短词 / 全大写默认不改,仅保留同长换位(`Teh` → `The`)。机器改写不再给新词加分;拒绝纠错会学会原文。
|
- **English autocorrect conservatism**: Title Case / short / ALL CAPS tokens are not replaced, except same-length transpositions (`Teh` → `The`). Machine-applied corrections no longer boost the replacement; rejecting them learns the original. / **英文自动更正更克制**:Title Case / 短词 / 全大写默认不改,仅保留同长换位(`Teh` → `The`)。机器改写不再给新词加分;拒绝纠错会学会原文。
|
||||||
- **Chinese user-dict flush**: leaving the typing surface finalizes librime so user-frequency ticks persist; secure fields insert Latin and skip Rime so passwords are not learned. / **中文用户词落盘**:离开打字表面时 finalize librime,使用频度得以保存;安全输入框改为直接插入拉丁字母、不进 Rime,避免把密码写入用户词库。
|
- **Chinese user-dict flush**: leaving the typing surface finalizes librime so user-frequency ticks persist; secure fields insert Latin and skip Rime so passwords are not learned. / **中文用户词落盘**:离开打字表面时 finalize librime,使用频度得以保存;安全输入框改为直接插入拉丁字母、不进 Rime,避免把密码写入用户词库。
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Keyboard switch crash**: `requestSupplementaryLexicon` completion hops to the main actor before writing session state, so switching to OSG Keyboard no longer traps on `com.apple.TextInput.lexicon-request`. / **切换键盘崩溃**:`requestSupplementaryLexicon` 回调先回到主线程再写会话状态,切换到 OSG Keyboard 不再在 `com.apple.TextInput.lexicon-request` 上触发隔离断言。
|
|
||||||
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
|
|
||||||
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
|
||||||
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
|
|
||||||
- **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos` 和 `OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。
|
- **Bundled Shortcut names**: Tasks and Events now install their signed in-app resources as `OSGExtractTodos` and `OSGExtractEvents`, matching `OSGSaveToNotes`; existing Chinese-named copies must be replaced from the Skills tab. / **内置捷径名称**:待办与日程改为安装 App 内签名资源 `OSGExtractTodos` 和 `OSGExtractEvents`,与 `OSGSaveToNotes` 保持一致;已有中文名称版本需从技能页重新安装。
|
||||||
- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。
|
- **Navigation icons**: Skills uses a wand; Styles uses a dial. The phone dock and iPad sidebar (except Home) use outline when idle and fill when selected. Mac Styles / Settings follow the same pairing. iPad and Mac Home stay the house icon. / **导航图标**:技能改为魔杖,风格改为旋钮。手机 Dock 与 iPad 侧栏(除首页外)未选中描边、选中填充;Mac 的风格 / 设置同样切换。iPad / Mac 首页仍用房子图标。
|
||||||
- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。
|
- **Phone dock size**: slightly smaller — 22 pt icons, 46 pt rows, 8 pt glass padding. / **手机 Dock 尺寸**:略缩小,图标 22 pt、行高 46 pt、玻璃内边距 8 pt。
|
||||||
@@ -59,95 +87,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
|
- **Clipboard skill circles**: Reply / Summarize / Translate idle buttons match the same 52 pt circle. / **剪贴板技能圆钮**:回复 / 总结 / 翻译空闲按钮与语音侧键同为 52 pt 圆。
|
||||||
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
|
- **Home tab selection**: the dock stays Liquid Glass at its original height; the selected tab is a wider green fill capsule with a 5 pt inset, not a second glass chip. Dock items are 24 pt icons with Home / Skills / Styles / Settings labels. / **首页 Tab 选中态**:dock 仍是原高度 Liquid Glass;选中项改为更宽的绿色填充胶囊,距栏边 5 pt,不再套第二层玻璃。dock 为 24 pt 图标加「首页 / 技能 / 风格 / 设置」文字。
|
||||||
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
|
- **Home library card titles**: History and Personal dictionary headers use a 16 pt icon and 13 pt label. / **首页资料卡标题**:历史与个性词库标题改为 16 pt 图标、13 pt 文字。
|
||||||
|
- **App navigation redesign**: Home becomes the overview with statistics, History, and Personal Dictionary cards; iPhone uses a four-destination Liquid Glass dock, while iPad uses an aligned sidebar + detail workspace. / **App 导航重构**:首页成为统计、历史与个性词库资料卡的总览;iPhone 使用四入口 Liquid Glass Dock,iPad 使用对齐的侧栏 + 内容区工作台。
|
||||||
|
- **Unified Undo and keyboard chrome**: one Undo action covers dictation, AI answers, edits, and clipboard pastes; Translation moves beside the voice microphone while the top-bar slot becomes Clipboard. / **统一撤销与键盘界面**:同一个撤销操作覆盖听写、AI 回答、编辑与剪贴板粘贴;翻译移到语音麦克风旁,顶栏原位置改为剪贴板入口。
|
||||||
|
- **User-owned AI keys**: remove the built-in DeepSeek fallback; polish and AI mode use the provider and API key configured by the user. / **用户自备 AI Key**:移除内置 DeepSeek 回退;润色与 AI 模式统一使用用户配置的服务商和 API Key。
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Hold-to-command clipboard flow**: long-pressing the microphone now edits the last verified OSGKeyboard input; Clipboard History, spoken AI clipboard requests, and quick skills replace the previous one-shot clipboard voice-command path. / **长按剪贴板语音流程**:长按麦克风现用于编辑最近一次经验证的 OSGKeyboard 输入;剪贴板历史、AI 口述剪贴板请求与快捷技能取代原来的一次性剪贴板语音指令。
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Keyboard switch crash**: `requestSupplementaryLexicon` completion hops to the main actor before writing session state, so switching to OSG Keyboard no longer traps on `com.apple.TextInput.lexicon-request`. / **切换键盘崩溃**:`requestSupplementaryLexicon` 回调先回到主线程再写会话状态,切换到 OSG Keyboard 不再在 `com.apple.TextInput.lexicon-request` 上触发隔离断言。
|
||||||
|
- **Save to Notes Shortcut**: bind the combined first-line title and clipboard body through iPhone Create Note’s real `WFCreateNoteInput` field. The previous `contents` binding was ignored and left an enter-content sheet or a title-only note. / **存入备忘录捷径**:通过 iPhone「创建备忘录」真正的 `WFCreateNoteInput` 字段绑定首行标题与剪贴板正文;旧版 `contents` 绑定会被忽略,导致弹出内容填写框或只生成标题。
|
||||||
|
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
|
||||||
|
- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
|
||||||
|
- **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
|
||||||
- **Clipboard skills jumped to a sentence chip**: copying no longer swaps the three skill buttons for a leftover carousel card such as “translate the clipboard”. Clipboard sentence cards stay out of the idle rotation. / **剪贴板技能跳成句子芯片**:复制后不再把三个技能按钮换成「把剪贴板翻译成…」这类旧轮播卡片;剪贴板句子不再进入空闲轮播。
|
- **Clipboard skills jumped to a sentence chip**: copying no longer swaps the three skill buttons for a leftover carousel card such as “translate the clipboard”. Clipboard sentence cards stay out of the idle rotation. / **剪贴板技能跳成句子芯片**:复制后不再把三个技能按钮换成「把剪贴板翻译成…」这类旧轮播卡片;剪贴板句子不再进入空闲轮播。
|
||||||
- **Clipboard skill status XML**: tapping Reply / Summarize / Translate no longer flashes the internal `<clipboard_request>` envelope above the mic. / **剪贴板技能状态 XML**:点回复 / 总结 / 翻译不再把内部 `<clipboard_request>` 信封闪现在麦克风上方。
|
- **Clipboard skill status XML**: tapping Reply / Summarize / Translate no longer flashes the internal `<clipboard_request>` envelope above the mic. / **剪贴板技能状态 XML**:点回复 / 总结 / 翻译不再把内部 `<clipboard_request>` 信封闪现在麦克风上方。
|
||||||
- **Idle keyboard re-adopted recognition**: aborting a session now remembers that utterance id, so a lagging host `processing` snapshot cannot reopen the voice keyboard in 「识别中」. / **空闲打开误进识别中**:中止会话会记住该句 id,滞后的主机 processing 快照不会在下次打开语音键盘时显示「识别中」。
|
- **Idle keyboard re-adopted recognition**: aborting a session now remembers that utterance id, so a lagging host `processing` snapshot cannot reopen the voice keyboard in 「识别中」. / **空闲打开误进识别中**:中止会话会记住该句 id,滞后的主机 processing 快照不会在下次打开语音键盘时显示「识别中」。
|
||||||
- **Voice cancel chrome until result**: Cancel (X) stays up through ASR/polish and through abort wait after leaving AI Agent, so the voice mic no longer looks ready while the host is still busy. / **语音取消直到出结果**:X 一直保留到识别/润色结束,以及离开 AI Agent 后的中断收尾;语音麦克风不再在宿主仍忙时显示为可用。
|
- **Voice cancel chrome until result**: Cancel (X) stays up through ASR/polish and through abort wait after leaving AI Agent, so the voice mic no longer looks ready while the host is still busy. / **语音取消直到出结果**:X 一直保留到识别/润色结束,以及离开 AI Agent 后的中断收尾;语音麦克风不再在宿主仍忙时显示为可用。
|
||||||
- **Empty double-tap skip**: a mic press shorter than 300 ms with near-silence (or no samples) is discarded before ASR, so accidental double taps no longer wait on a no-speech error. / **空连点跳过**:按下短于 300ms 且接近静音(或尚无样本)的录音在进入识别前丢弃,避免误触后长时间等待「没听清」。
|
- **Empty double-tap skip**: a mic press shorter than 300 ms with near-silence (or no samples) is discarded before ASR, so accidental double taps no longer wait on a no-speech error. / **空连点跳过**:按下短于 300ms 且接近静音(或尚无样本)的录音在进入识别前丢弃,避免误触后长时间等待「没听清」。
|
||||||
- **AI hint left the mic stuck**: prefilled AI questions now drop the host processing gate after the answer or error; consuming an ack for the live utterance also heals a leaked gate. Reopening the keyboard only aborts when App Group already acked that utterance and the result is gone — a live LLM/ASR wait with no result yet is left running. Cancel during generate no longer delivers the answer afterwards. / **AI 建议卡住麦克风**:预填 AI 问题在出答案或失败后会关掉宿主 processing 闸门;若键盘已 ack 当前句而闸门仍开着,宿主消费 ack 时一并放闸。再次打开键盘仅在 App Group 已 ack 且结果已空时才中断残留闸门;LLM/ASR 仍在跑、尚无结果时继续等待。生成中取消后不再把答案写回来。
|
- **AI hint left the mic stuck**: prefilled AI questions now drop the host processing gate after the answer or error; consuming an ack for the live utterance also heals a leaked gate. Reopening the keyboard only aborts when App Group already acked that utterance and the result is gone — a live LLM/ASR wait with no result yet is left running. Cancel during generate no longer delivers the answer afterwards. / **AI 建议卡住麦克风**:预填 AI 问题在出答案或失败后会关掉宿主 processing 闸门;若键盘已 ack 当前句而闸门仍开着,宿主消费 ack 时一并放闸。再次打开键盘仅在 App Group 已 ack 且结果已空时才中断残留闸门;LLM/ASR 仍在跑、尚无结果时继续等待。生成中取消后不再把答案写回来。
|
||||||
|
- **Chinese input setup and recovery**: Rime deploys deterministically during onboarding and host-app startup; an already-open keyboard retries automatically after deployment instead of remaining stuck on the setup error. / **中文输入初始化与恢复**:Rime 在引导和主 App 启动时确定性部署;部署完成后已打开的键盘会自动重试,不再长期停留在初始化错误。
|
||||||
## [1.7.5] - 2026-08-13
|
- **Universal Clipboard and presentation stability**: pasteboard reads move off the main thread and start after presentation, preventing cross-device copies from freezing or blanking the keyboard. / **通用剪贴板与呈现稳定性**:剪贴板读取移出主线程并延后到键盘呈现完成后,避免跨设备复制使键盘卡住或空白。
|
||||||
|
- **Question-preserving polish**: all built-in and custom styles keep question drafts as questions, with deterministic fallback when a model attempts to answer the user instead. / **保留问句的润色**:全部内置与自定义风格都会保留问句;模型若试图替用户回答,会确定性回退到用户原意。
|
||||||
### Added
|
|
||||||
- **AI idle hint carousel**: AI mode empty state rotates one-line suggestions (local evergreen + optional remote hot topics); tap sends the card prompt to the LLM without the mic. Coexists with the clipboard suggestion strip in the top bar. / **AI 空闲建议轮播**:AI 模式空状态轮播单行建议(内置常驻 + 可选远程热点);点按即跳过麦克风把卡片 prompt 发给模型。与顶栏剪贴板建议条并存。
|
|
||||||
- **Hint feed refresh**: main app silently fetches `https://key.osglab.com/hints` about every 12 hours, compresses titles with the user’s polish LLM, and writes App Group ready packs for the keyboard. / **建议源刷新**:主 App 约每 12 小时静默拉取 `https://key.osglab.com/hints`,用用户润色 LLM 压缩标题,写入 App Group 供键盘只读。
|
|
||||||
- **Clipboard history**: optional keyboard clipboard history (latest 15 plain-text items, App Group local), top-bar clipboard entry, history panel with whitespace token chips, and a suggestion strip across voice / typing / AI when enabled. Within ~30s of a copy, AI idle hints can prefer clipboard-related cards. / **剪贴板历史**:可选的键盘剪贴板历史(最近 15 条纯文本、App Group 本地),顶栏剪贴板入口、带空白分词芯片的历史面板,以及开启后在语音 / 打字 / AI 面显示的建议条;复制后约 30 秒内 AI 空闲建议可偏向剪贴板相关卡片。
|
|
||||||
- **Clipboard settings**: Settings home adds a Clipboard page under AI Agent with History and Suggestion-strip toggles (both default off); keyboard deep-links open that page. / **剪贴板设置**:设置首页在 AI Agent 下方新增「剪贴板」页,含历史记录与候选栏展示开关(均默认关闭);键盘可深链打开该页。
|
|
||||||
- **Paste permission guidance**: the Clipboard settings page explains iOS's durable “Paste from Other Apps” permission and opens iOS Settings directly, so users can set it to Allow once and stop the per-copy paste prompt. / **粘贴授权引导**:剪贴板设置页说明 iOS 的「从其他 App 粘贴」持久授权并可直接跳转系统设置,用户设为「允许」一次即不再每次复制都弹窗。
|
|
||||||
- **Clipboard settings copy**: shorten history, suggestion-strip, and paste-permission wording to brief user-facing lines. / **剪贴板设置文案**:精简历史、建议条与粘贴授权说明,改为简短面向用户的表述。
|
|
||||||
- **Settings summary placement**: navigation-row summaries (AI Agent, Clipboard, etc.) sit trailing before the chevron, matching the Version row. / **设置摘要位置**:导航行摘要(AI Agent、剪贴板等)移到右侧 chevron 前,与「版本」行一致。
|
|
||||||
- **AI answer streaming**: AI mode streams visible answer text into the keyboard as the model writes (all AI-mode transports), with throttled App Group updates, search-fallback draft restart, and a “thinking” status before the first token; dictation polish stays non-streaming. / **AI 回答流式输出**:AI 模式在模型开始写正文后将可见答案增量推送到键盘(覆盖全部 AI 传输路径),经 App Group 节流更新;搜索失败回退会清空半截草稿;首 token 前显示「思考中」;听写润色仍为整段返回。
|
|
||||||
- **Spoken clipboard requests**: in AI mode, naming the clipboard out loud (“reply to the clipboard”, “translate my clipboard”) now attaches the stored clipboard text to that question. Naming it is the authorization, so no second confirmation appears; every other AI question is still sent without clipboard text, and with Clipboard History off the request fails with a clear reason instead of guessing. / **口述剪贴板指令**:AI 模式中说出「回复剪贴板」「把剪贴板翻译成英文」等,会把已保存的剪贴板正文附加到该问题。说出即视为授权,不再二次确认;其余 AI 提问一律不附带剪贴板;未开启剪贴板历史时会给出明确原因而非自行猜测。
|
|
||||||
- **AI Agent settings**: Settings home adds an AI Agent row under General, with a Response length preference (Short / Medium / Detailed, default Medium). AI mode injects soft length guidance into the system prompt and syncs the choice via iCloud settings. / **AI Agent 设置**:设置首页在「通用」下方新增 AI Agent 入口,支持「回复篇幅」(简短 / 中等 / 详细,默认中等)。AI 模式将篇幅作为软约束写入 system prompt,并纳入 iCloud 设置同步。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Home library cards**: History and Personal dictionary leave the bottom dock; Home shows two self-sizing preview cards — transcripts split by hairlines (two lines each) and dictionary terms with their source/usage detail line — that push the existing pages with a standard back button. / **首页资料卡片**:历史记录与个性词库移出底部 Dock;首页改为两张按内容自适应高度的预览卡——历史条目用细线分隔(每条最多两行),词库显示词条与来源/使用次数小字——点按后以标准返回按钮进入原页面。
|
|
||||||
- **Home scroll status**: engine and session status scroll with the page at the bottom (dock clearance via tab-bar padding) instead of a pinned footer. / **首页滚动状态**:引擎与会话状态随页面滚到底部(Dock 留白与设置页一致),不再钉在底部。
|
|
||||||
- **Clipboard AI prompt contract**: clipboard text now reaches the model as a separate escaped `clipboard_text` block beside the instruction, and the AI system prompt treats that block as untrusted content instead of instructions. Hint cards carry only the instruction. / **剪贴板 AI 提示词契约**:剪贴板正文改为与指令并列的独立转义 `clipboard_text` 块,AI system prompt 明确将该块视为不可信内容而非指令;建议卡片只保留指令本身。
|
|
||||||
- **Hint feed freshness**: refresh tracks success per locale (a Chinese success no longer masks an English failure), a manifest failure no longer aborts the packs, and a pack past its `expiresAt` — or older than 48 hours without one — falls back to the built-in evergreen catalog instead of showing stale hot topics. / **建议源新鲜度**:刷新按语言分别记录成功(中文成功不再掩盖英文失败),manifest 失败不再中断整轮;超过 `expiresAt`(或无该字段且超过 48 小时)的建议包回退到内置常驻卡片,不再展示陈旧热点。
|
|
||||||
- **Privacy policy**: document AI idle suggestions, opt-in clipboard history (panel, suggestion strip, and AI hint use), and when clipboard text is sent to your AI provider. / **隐私政策**:补充 AI 空闲建议、可选剪贴板历史(面板、建议条与 AI 提示用途),以及剪贴板正文在何种情况下会发送给 AI 服务商。
|
|
||||||
- **Clipboard history row opacity**: history panel entry cards use 50% surface opacity so the keyboard chrome shows through. / **剪贴板历史条目透明度**:历史面板每条记录背景改为 50% 透明度,键盘底色可透出。
|
|
||||||
- **Translation button chrome**: voice mic-row translation control matches the adjacent undo key (44×44 rounded rectangle) instead of a circular chip. / **翻译按钮样式**:语音麦克风行的翻译控件改为与相邻撤销键一致的 44×44 圆角矩形,不再使用圆形芯片。
|
|
||||||
- **Undo covers clipboard pastes**: the undo key now rolls back text inserted from the clipboard suggestion strip or history panel, in one step regardless of length. Pasted text stays out of dictation history and is never offered for last-input editing. / **撤销支持剪贴板粘贴**:撤销键现在可回滚从剪贴板建议条或历史面板插入的文字,无论长度都一次撤销到底。粘贴内容不进入听写历史,也不会成为「编辑上次输入」的对象。
|
|
||||||
- **Undo key label**: rename the accessibility label from “Undo last dictation” to “Undo last input” now that it covers dictation, AI answers, edits and pastes. / **撤销键文案**:无障碍标签由「撤销上次听写」改为「撤销上次输入」,因其现已覆盖听写、AI 答案、编辑与粘贴。
|
|
||||||
- **Translation chip placement**: move the top-bar translation control onto the voice mic row, mirrored with Undo; the top-bar slot becomes the clipboard button. / **翻译入口位置**:顶栏翻译控件移到语音麦克风行并与撤销对称;原顶栏位置改为剪贴板按钮。
|
|
||||||
- **AI empty-state tip**: center “Tap the microphone to ask AI” in the answer area (horizontal + vertical); idle state now rotates hint cards there. / **AI 空状态指引**:答案区域水平与垂直居中展示空闲建议轮播(替代静态「点击麦克风」文案)。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Clipboard hints missed a fresh copy**: the AI idle carousel now refreshes from the clipboard store as soon as a copy is captured, instead of waiting for the next 4-second rotation or a re-entry into AI mode — the reason clipboard suggestions often never appeared inside their 30-second window. / **新复制无剪贴板建议**:AI 空闲轮播在采集到新复制时立即依据剪贴板刷新,不再等待下一次 4 秒轮换或重新进入 AI 模式——这正是 30 秒窗口内常常看不到剪贴板建议的原因。
|
|
||||||
- **Reduce Motion froze hint data**: Reduce Motion now only stops the fade rotation. Cards still refresh, so a new copy appears and a card whose clipboard window closed leaves the carousel. / **Reduce Motion 冻结建议数据**:辅助功能「减弱动态效果」现在只停止渐隐轮换,数据仍会刷新:新复制会出现,剪贴板窗口关闭的卡片会退出轮播。
|
|
||||||
- **Expired clipboard hint tap**: tapping a clipboard card after its 30-second window fails closed with a “copy the text again” message instead of sending the instruction with empty material. / **过期剪贴板建议点击**:超过 30 秒窗口后点击剪贴板卡片会明确提示重新复制,而不是把缺少材料的指令发出去。
|
|
||||||
- **Blank keyboard on cross-device clipboard**: clipboard-history capture no longer reads `UIPasteboard` on the main thread or during the appear sequence — a Universal Clipboard item still being fetched from another device blocked the main thread for seconds, freezing the keyboard mid-presentation. Reads now run on a background queue, start on the first poll tick after the slide-in, and never overlap. / **跨设备剪贴板导致键盘空白**:剪贴板历史采集不再在主线程、也不再在键盘出现流程中读取 `UIPasteboard`——待从其他设备拉取的通用剪贴板内容会同步阻塞主线程数秒,使键盘卡在呈现过程中。读取改到后台队列、延后到滑入完成后的首次轮询,且不会重叠执行。
|
|
||||||
- **Keyboard presentation height**: drop the `target − system encapsulated height` priming trick. It read the pre-presentation full-screen height (874 pt on an iPhone) rather than the keyboard slot, clamped our constraint to 0, and lost to the system's required constraint anyway; the surface is now bottom-anchored so a transient over-tall container can never park it above the visible slot. / **键盘呈现高度**:移除「目标高度 − 系统封装高度」的预置技巧。它读到的是呈现前的整屏高度(iPhone 上 874pt)而非键盘槽,把约束夹成 0,且本来就压不过系统的 required 约束;键盘内容改为底部锚定,容器临时过高时不会再被顶到可见区域之外。
|
|
||||||
- **Undo of long insertions**: caret verification now compares the tail of the inserted text's last line instead of the whole string, so long or multi-line insertions no longer lose undo the moment the host returns a truncated context. / **长文本撤销**:光标校验改为比对插入文本最后一行的尾部而非整段,长文本或多行插入不再因宿主返回截断上下文而立刻失去撤销能力。
|
|
||||||
- **Undo after editing last input**: a newer insertion now clears the pending edit transaction, so undo rolls back that insertion instead of deleting it and restoring the older edit's original text. / **编辑上次输入后的撤销**:新的插入会清除待撤销的编辑事务,撤销将回滚该次插入,而不再是删除它并还原上一次编辑前的旧文本。
|
|
||||||
- **AI waiting spinner duplicate**: remove the mini ProgressView beside the AI status caption; the mic button spinner remains the sole loading indicator while recognizing or generating. / **AI 等待转圈重复**:去掉 AI 状态文案旁的迷你 ProgressView;识别/生成中仅保留麦克风按钮上的 loading。
|
|
||||||
- **AI stream UTF-8 mojibake**: SSE framing now accumulates raw bytes and decodes each line as UTF-8, so Chinese AI answers (e.g. weather) no longer appear as Latin-1 garbage like `ä»å¤©…`. / **AI 流式 UTF-8 乱码**:SSE 行缓冲改为累积原始字节并以 UTF-8 解码,中文 AI 回答(如天气)不再显示为 `ä»å¤©…` 一类 Latin-1 乱码。
|
|
||||||
- **Local ASR dictionary correction**: apply deterministic personal-dictionary alias correction before iOS Flow branches into dictation polish or AI question handling, so AI mode keeps local transcript optimization while still skipping cloud LLM polish. / **本地 ASR 词库纠错**:在 iOS Flow 分流到听写润色或 AI 问答前统一应用个人词库别名确定性纠错,使 AI 模式保留本地转写优化,同时继续跳过云端 LLM 润色。
|
|
||||||
|
|
||||||
## [1.7.0] - 2026-08-10
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- **AI keyboard mode**: add a temporary multi-turn AI surface that sends raw ASR questions to the configured LLM, keeps the latest scrollable answer for review, and uses a two-step white Insert then green Send action in messaging fields; only inserted answers enter history and AI character statistics. / **AI 键盘模式**:新增临时多轮 AI 输入面,将原始 ASR 问题直接交给已配置模型,滚动展示最新答案,并在即时通信输入框中采用先点白色「插入」、再点绿色「发送」的两步操作;只有真正插入的答案会进入历史与 AI 字数统计。
|
|
||||||
- **AI mode web search**: AI questions use a dedicated transport that enables provider-side search when available (DeepSeek/OpenAI/xAI Responses `web_search`, Qwen `enable_search`, Zhipu/Anthropic/Moonshot tools), forces thinking on, and silently retries without search on failure — polish stays on plain Chat Completions. / **AI 模式联网搜索**:AI 问答走独立传输层,在服务商支持时启用服务端搜索(DeepSeek/OpenAI/xAI Responses `web_search`、通义 `enable_search`、智谱/Anthropic/Moonshot tools),强制开启 thinking,失败则静默无搜索重试;润色仍走普通 Chat Completions。
|
|
||||||
- **API key setup guidance**: Home shows a tip when the polish LLM key is missing; the keyboard mic line warns above the microphone. Without a key, dictation still inserts raw ASR text. / **API Key 引导**:未填写润色 API Key 时首页显示提示,键盘麦克风上方同步提醒;无 Key 时听写仍插入原始识别结果。
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **User-owned polish keys only**: remove the built-in DeepSeek `PreconfiguredKeys` fallback. Local and cloud polish (and AI mode) require a user-filled API key; AI question timeout is 60s. / **仅使用用户 API Key**:移除内置 DeepSeek `PreconfiguredKeys` 回退。本地/云端润色与 AI 模式均需用户自行填写 API Key;AI 问答超时为 60 秒。
|
|
||||||
- **OpenAI default model**: preset default is now `gpt-5.4-mini` (Responses `web_search` capable). Existing saved model names are unchanged. / **OpenAI 默认模型**:预设改为 `gpt-5.4-mini`(支持 Responses 联网搜索);用户已保存的模型名不受影响。
|
|
||||||
- **LLM preset defaults refreshed**: update defaults for Qwen (`qwen-plus-latest`), Zhipu (`glm-4.7-flash`), Moonshot (`kimi-k2.5`), xAI (`grok-4-fast-reasoning`), Gemini (`gemini-3.1-flash-lite`), MiniMax (`MiniMax-M2.7`), SiliconFlow / OpenRouter / CometAPI / CodingPlanX; Anthropic stays on `claude-sonnet-4-6`. Polish and AI mode both resolve the Settings provider/model via the same endpoint helper. / **LLM 预设默认值更新**:通义 / 智谱 / Moonshot / xAI / Gemini / MiniMax 及部分聚合预设已换新默认模型;Anthropic 仍为 `claude-sonnet-4-6`。润色与 AI 模式共用同一套设置中的服务商与模型解析。
|
|
||||||
- **Privacy policy**: document user-owned LLM keys, AI-mode optional provider web search, and remove built-in DeepSeek wording (in-app + GitHub Pages). / **隐私政策**:说明用户自备 LLM Key、AI 模式可选服务商联网搜索,并移除内置 DeepSeek 表述(App 内 + GitHub Pages)。
|
|
||||||
- **iPad system globe key**: add the system 🌐 key on both iPad voice and typing surfaces — tap to switch to the next keyboard, long-press to open the system input-mode picker. The key uses UIKit's standard all-touch-events input-mode action and the same SwiftUI chrome as adjacent action keys; iPhone relies on its system-provided switch below the keyboard. / **iPad 系统地球键**:在 iPad 语音面与打字面均新增系统 🌐 键——轻点切换下一个键盘,长按打开系统键盘列表。按键采用 UIKit 标准全触摸事件输入模式动作,并与相邻功能键共用同一套 SwiftUI 键面;iPhone 则使用键盘下方由系统提供的切换入口。
|
|
||||||
- **iPad typing layout**: the typing keyboard now adapts to iPad — taller 54 pt key rows, a second-row inset that widens in landscape (40 pt) vs portrait (30 pt), a small grey number overlay (1–0) on the top letter row mirroring iOS, and a content-driven height (≈300 pt on iPad vs 281 pt on iPhone) so the bottom row is never clipped. Metrics live in `TypingLayoutMetrics` and are selected from one controller-owned device-idiom + horizontal-size-class decision. / **iPad 打字布局**:打字键盘现已适配 iPad——键行加高至 54pt,第二行缩进在横屏(40pt)比竖屏(30pt)更宽,首字母行叠加 1–0 小号灰数字(对齐 iOS),并改为内容驱动高度(iPad 约 300pt,iPhone 281pt),底部行不再被裁切。尺寸由控制器统一结合设备类型与水平尺寸类判定。
|
|
||||||
- **Editing toolbar (iPad)**: the typing top bar gains an iOS-style undo / redo / copy / cut cluster on iPad. Undo/redo track the last voice insertion (redo re-applies it); copy/cut read the host selection. Availability is refreshed from `textDidChange` / `selectionDidChange`. / **编辑工具栏(iPad)**:打字面顶栏在 iPad 上新增类 iOS 的撤销/重做/拷贝/剪切簇。撤销/重做跟踪上次听写插入(重做可复原),拷贝/剪切读取宿主选区;可用性随 `textDidChange` / `selectionDidChange` 刷新。
|
|
||||||
- **Edit last input**: long-press the microphone to describe changes to the last verified OSGKeyboard insertion, then preview and replace or append the validated result without exposing unrelated field text. / **编辑上次输入**:长按麦克风口述对最近一次已验证 OSGKeyboard 输入的修改,预览后可替换原文或追加结果,且不暴露无关输入框内容。
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
- **Clipboard voice commands**: remove the iOS keyboard's long-press clipboard capture, persisted resume state, clipboard prompt pipeline, and related pasteboard access; normal dictation, last-input editing, main-app/macOS copy features, and keyboard copy/cut remain available. / **剪贴板语音指令**:移除 iOS 键盘长按读取剪贴板、持久化恢复状态、剪贴板提示词链路及相关粘贴板访问;普通听写、编辑上次输入、主 App/macOS 独立复制功能与键盘拷贝/剪切继续保留。
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Focused recording chrome and cancellation**: edit mode now aligns its logo and close button to the same outer inset as the normal voice surface. During normal voice startup, recording, and processing, the capsule tabs and translation button are replaced by a high-visibility cancel button; cancelling aborts ASR and polish and discards any late result. Disabled undo and bottom action keys stay hidden so the live microphone remains the unambiguous focus. / **聚焦录音界面与取消入口**:编辑模式的 Logo 与关闭按钮现与普通语音界面使用相同外边距。普通语音从启动、录音到处理期间,胶囊标签与翻译按钮会替换为高可见度取消按钮;取消会终止 ASR 与润色,并丢弃任何迟到结果。不可用的撤销键和底部操作键保持隐藏,让正在工作的麦克风成为明确焦点。
|
|
||||||
- **Repeat edit sessions**: completed edits no longer re-adopt stale recording snapshots and enter an eight-second busy quarantine, so the next long-press starts ASR immediately with the latest applied text. The edit surface enlarges its comparison swipe area, shows live ASR text and an audio-level waveform instead of status copy, widens the centred edit microphone into a three-times-wide capsule, and keeps the green “hold to edit” hint visible for ten seconds. / **连续编辑会话**:已完成的编辑不再重新接管过期录音快照并进入八秒忙碌隔离,下一次长按会立即基于最新已应用文本启动 ASR。编辑界面同时扩大对比滑动热区,以实时 ASR 文本与音量波形替代状态文案,将居中的编辑麦克风加宽为原宽度三倍的胶囊按钮,并将绿色“长按编辑”提示延长至十秒。
|
|
||||||
- **Faster first mic and stable edit review**: each host process performs one bounded post-PiP audio health check (real first-frame proof, one soft-dead rebuild, then release), while touching the main mic primes the same single-flight capture for the imminent tap/hold utterance. Edit review now keeps its Flow identity until confirm/close, deduplicates final results by utterance+revision, and uses native horizontal paging; a simulator XCUITest verifies left/right swipes over the text area. / **更快首录与稳定编辑预览**:每个宿主进程在 PiP 就绪后执行一次有界音频健康检查(真实首帧证明、软死时最多重建一次、随后释放),手指按下主麦克风时则预热同一个单飞 capture,供即将成立的短按/长按 utterance 直接接管。编辑预览在确认/关闭前持续保留 Flow 身份,按 utterance+revision 去重结果,并改用原生横向分页;模拟器 XCUITest 已验证文字区域左右滑动。
|
|
||||||
- **Unified edit-mode recording**: dictation and last-input editing now share one `FlowUtteranceRequest` start transaction for host handoff, microphone activation, recording confirmation, stop, timeout, and ASR; edit mode no longer pre-allocates a ghost utterance or runs its own confirmation polling. The initiating long press is consumed before switching views, preventing a replayed finger-up from sending `stopRecording`. / **统一编辑模式录音**:普通听写与编辑上次输入现共用同一个 `FlowUtteranceRequest` 启动事务,统一处理宿主交接、麦克风激活、录音确认、停止、超时与 ASR;编辑模式不再预创建幽灵 utterance,也不再维护独立确认轮询。切换界面前先消费发起编辑的长按,避免抬手被重放为 `stopRecording`。
|
|
||||||
- **iPad keyboard width**: the typing keyboard was clamped to a 700 pt centred column on every device, leaving 67 pt of dead space per side on an 11" iPad in portrait and 247 pt in landscape (51% of the screen on a 13" in landscape), so no key sat where the system keyboard puts it. The cap was a voice-surface ergonomics constraint (`contentMaxWidth`) that the key grid had inherited; it is now voice-only (`voiceContentMaxWidth`) and the typing grid spans the full host width. Letter keys grow from 61 pt to 75 pt wide on an 11" iPad in portrait. / **iPad 键盘宽度**:打字键盘此前在所有设备上都被钳制为 700pt 居中列,11 寸 iPad 竖屏每侧留白 67pt、横屏 247pt(13 寸横屏占屏幕 51%),导致没有一个按键落在系统键盘的位置上。该上限原是语音面的可达性约束(`contentMaxWidth`),却被键盘网格一并继承;现已改为语音面专用(`voiceContentMaxWidth`),打字网格铺满宿主宽度。11 寸 iPad 竖屏字母键宽由 61pt 增至 75pt。
|
|
||||||
- **iPad keyboard height ignored orientation**: `contentHeight` hardcoded `isLandscape: true` while orientation only fed the second-row inset, so portrait and landscape resolved to an identical 300 pt. Height is now driven by available width (394 pt at iPad landscape widths vs 300 pt in portrait, against roughly 398 pt / 313 pt for the system keyboard), which also fixes `traitCollectionDidChange` never firing on iPad rotation — both orientations are `regular`, so the resize is now picked up in `viewDidLayoutSubviews`. / **iPad 键盘高度不随方向变化**:`contentHeight` 硬编码 `isLandscape: true`,而方向仅用于第二行缩进,横竖屏因此解析出完全相同的 300pt。现改由可用宽度驱动(iPad 横屏宽度下 394pt、竖屏 300pt,系统键盘约为 398pt / 313pt),同时修复 iPad 旋转不触发 `traitCollectionDidChange` 的问题——横竖屏同为 `regular`,改在 `viewDidLayoutSubviews` 捕获尺寸变化。
|
|
||||||
- **Second-row indent no longer a fixed constant**: the ASDF row used a hardcoded 30 / 40 pt inset tuned for a 700 pt column, which stops reading as a deliberate indent once the grid fills an iPad. It is now derived so second-row keys are exactly as wide as first-row keys, leaving a half key at each end — the system keyboard's own rule, and correct at any width. / **第二行缩进不再是固定常量**:ASDF 行原用为 700pt 列调校的 30 / 40pt 硬编码缩进,网格铺满 iPad 后已无法读作有意的缩进。现改为推导值,使第二行键宽与第一行完全一致、两端各留半个键——即系统键盘自身的规则,在任意宽度下都成立。
|
|
||||||
- **iPad bottom row gains comma / period**: filling the width turned the phone row's 50% centre fraction into a 663 pt space bar on a 13" iPad in landscape. iPad now uses a six-slot row `[globe · 123 · , · space · . · return]` (9/13/9/38/9/22), spending the extra width on keys the way the system keyboard does; space settles at 432 pt on an 11" in landscape against roughly 430 pt for the system. Punctuation routes through the engine, so a pending composition commits first. / **iPad 底行新增逗号/句号**:铺满宽度后,手机版 50% 的中央比例会使 13 寸 iPad 横屏空格键达到 663pt。iPad 现采用六槽底行 `[globe · 123 · , · space · . · return]`(9/13/9/38/9/22),像系统键盘那样把多出的宽度用于增加按键;11 寸横屏空格为 432pt,系统约为 430pt。标点经由引擎提交,未上屏的编码会先行确认。
|
|
||||||
- **Voice surface matches the typing surface**: the voice surface also fills the host width, and both surfaces now resolve to one content-driven height, so switching between them no longer resizes the keyboard (a 113 pt jump on an iPad in landscape). Surplus height is parked above the action cluster, keeping its keys on the typing bottom row's baseline. Its bottom row uses a flatter iPad split (10/24/40/26), widening the primary return key without letting the phone's 50% centre fraction hand it ~577 pt at full width. / **语音面与打字面对齐**:语音面同样铺满宿主宽度,且两面现在解析出同一个内容驱动的高度,互相切换不再改变键盘尺寸(iPad 横屏原有 113pt 跳变)。多余高度置于操作簇上方,使其按键与打字面底行保持同一基线;底行在 iPad 上采用更平缓的比例(10/24/40/26),加宽主要回车键,同时避免手机版 50% 的中央比例让其在铺满时达到约 577pt。
|
|
||||||
- **Layout metrics are now testable**: typing size decisions moved from `TypingRootView` (extension target) into `TypingSurfaceMetrics` (shared), so the UIKit height constraint and the SwiftUI grid provably read the same source and unit tests can cover them without the extension. / **布局尺寸可测**:打字面尺寸决策由 `TypingRootView`(扩展 target)移至 `TypingSurfaceMetrics`(共享层),UIKit 高度约束与 SwiftUI 网格可证明地读取同一数据源,且单测无需依赖扩展即可覆盖。
|
|
||||||
- **Chinese input never initializes**: the keyboard could show 「请先打开 OSGKeyboard 完成输入法初始化」 indefinitely even after opening the app, because Rime deployment was scheduled only as opportunistic warmup — a 45 s delay that additionally required the app to stay foregrounded and to clear a memory gate, and silently dropped the work otherwise. Deployment is now also run deterministically at the moments the user is actually waiting on it: during the onboarding "Add Keyboard" step (with an inline progress / retry status row), immediately on onboarding completion, and on demand via a new `osgkeyboard://deployrime` deeplink. / **中文输入始终无法初始化**:即使已打开主 App,键盘仍可能长期显示「请先打开 OSGKeyboard 完成输入法初始化」——因为 Rime 部署只作为机会性预热调度:延迟 45 秒,且要求 App 保持前台并通过内存闸门,否则静默放弃。现在在用户真正等待结果的时机改为确定性执行:引导「添加键盘」步骤内(附行内进度 / 重试状态)、引导完成时立即执行,以及通过新增的 `osgkeyboard://deployrime` deeplink 按需触发。
|
|
||||||
- **Keyboard recovers without a restart**: a keyboard already showing the setup error now retries engine preparation when the host posts its post-deployment App Group notification, and the error text itself became a tappable jump into the host app (only for failures host deployment can actually fix). Previously the error persisted until the user switched surfaces or relaunched the keyboard. / **键盘无需重启即可恢复**:已显示初始化错误的键盘,会在宿主发出部署完成的 App Group 通知后自动重试引擎准备;错误文案本身也变为可点击跳转(仅对确实能由宿主部署修复的故障开放)。此前该错误会一直保留,直到用户切换面板或重新拉起键盘。
|
|
||||||
- **Rime deployment stays in the host app**: the shared installer now rejects every `.appex` process, and personal-dictionary callbacks skip deployment when invoked by the keyboard extension. Returning users trigger idempotent deployment immediately when the host opens instead of waiting for a 45-second opportunistic warmup. The host now clears its heavy-work flag before notifying the keyboard, so the extension's automatic retry cannot lose the only completion event to stale busy state. / **Rime 部署仅限主 App**:共享安装器现会拒绝所有 `.appex` 进程,个人词库回调若来自键盘扩展也会跳过部署。老用户打开主 App 时立即触发幂等部署,不再等待 45 秒的机会性预热;宿主还会先清除重任务标记再通知键盘,避免扩展自动重试因读到过期忙碌状态而错失唯一完成事件。
|
|
||||||
- **iPad globe key at bottom-left**: the system 🌐 key now lives at the bottom-left of both iPad surfaces — voice (`KeyboardRootView.micActionRow`) and typing (`TypingRootView.typingKeySurface`) — while iPhone removes the duplicate custom key and redistributes its three remaining actions across the row. UIKit owns the iPad key's complete native tap / long-press event chain, and its visible chrome matches adjacent keys. / **iPad 地球键移至左下角**:系统 🌐 键现位于 iPad 语音面与打字面的最左下角;iPhone 删除重复的自定义地球键,并将剩余三个操作键重新铺满底行。iPad 地球键由 UIKit 完整接管原生轻点/长按事件链,可见键面与相邻按键保持一致。
|
|
||||||
- **iPad typing height**: `TypingRootView.totalHeight` is now a function `totalHeight(isIPad:)`; `KeyboardViewController` resolves one layout mode from device idiom plus horizontal size class and publishes it to SwiftUI, keeping compact iPad multitasking on phone metrics and wide iPhones off iPad metrics. Trait changes refresh the height constraint so UIKit and SwiftUI stay aligned. / **iPad 打字高度**:`TypingRootView.totalHeight` 改为函数 `totalHeight(isIPad:)`;`KeyboardViewController` 结合设备类型与水平尺寸类统一解析布局模式并发布给 SwiftUI,使 iPad 紧凑分屏使用手机尺寸、宽屏 iPhone 不误用 iPad 尺寸;尺寸类变化时同步刷新高度约束,确保 UIKit 与 SwiftUI 始终一致。
|
|
||||||
- **iPad sidebar breathing room**: on the iPad / regular-width sidebar, the gap between the brand logo and the first menu item is roughly doubled (brand header bottom padding `Spacing.md` → `Spacing.xxxl`), and each sidebar row is taller with larger text (font 13 → 15, vertical padding 7 → 12) so rows hit the Apple HIG 44pt touch target. / **iPad 侧栏留白**:iPad / 横屏规则宽度侧栏里,logo 与首个菜单项之间的间距大约翻倍(`brandHeader.bottom` 由 `Spacing.md` 改为 `Spacing.xxxl`),每行菜单项更高、字号更大(字号 13 → 15、垂直内边距 7 → 12),行高达到 Apple HIG 44pt 触摸目标。
|
|
||||||
- **iPad sidebar row alignment**: sidebar titles started at a different x on every row because `Label`'s default style sizes the icon to each SF Symbol's intrinsic width, which ranges from 13 pt (`character.book.closed`) to 17.5 pt (`house`) across this menu — pushing 「键盘」 5.2 pt right of 「词库」. A `LabelStyle` now reserves a uniform 22 pt icon column (the alignment `List` provides for free and this hand-rolled `VStack` sidebar lacked), and the selected row's corner radius moves off a stray hardcoded 7 pt onto the design scale at `Radius.medium` (12 pt). / **iPad 侧栏行对齐**:侧栏各行文字起点不一致——`Label` 默认样式按每个 SF Symbol 的固有宽度排布图标,本菜单内该宽度从 13pt(`character.book.closed`)到 17.5pt(`house`)不等,使「键盘」比「词库」右移 5.2pt。现由 `LabelStyle` 预留统一的 22pt 图标列(`List` 自带、而手写 `VStack` 侧栏缺失的对齐),选中行圆角也从游离的硬编码 7pt 收回设计标度 `Radius.medium`(12pt)。
|
|
||||||
- **iPad Home hint position**: the keyboard-setup hint (and other flow-session extras) on the iPad / regular-width home view used to render below the usage-stats cards, burying the most actionable guidance beneath the stats. The hint now sits right after the hero header and above the stats, matching the phone layout, so it is the first thing a user notices at the top of the page. / **iPad 首页提示位置**:iPad / 横屏规则宽度首页的「去系统设置添加键盘」提示(以及其他 Flow 会话附注)原本位于使用统计卡片之下,最关键的引导被压在统计之下。现已将其移到 hero 标题与统计卡片之间,与手机端布局一致,使其位于页面顶部最显眼处。
|
|
||||||
- **Polish never answers a question draft**: dictating 「你能听到我说话吗?」 could come back as 「能听到,你说。」. The never-answer boundary and the question guard now ship with every style, every intensity, and custom packs (heavy fun styles previously bypassed both), question detection covers A-not-A forms such as 「你在不在」, and a deterministic validator rejects any result that stops asking the draft's question and falls back to a local clean of the user's own words. A 800-request live matrix across 10 styles × 2 intensities × 20 short-to-long phrasings now delivers zero answers. / **润色不再代答问句**:口述「你能听到我说话吗?」可能被润色成「能听到,你说。」。现在「不可协商边界」与问句守卫覆盖全部风格、全部强度与自定义风格包(此前重度趣味风格会绕过两者),问句识别补齐「你在不在」等正反问句式,并新增确定性校验:结果若不再是问句则丢弃,回退为用户原话的本地清理。10 风格 × 2 强度 × 20 种长短句式、共 800 次真实模型压测下代答为 0。
|
|
||||||
- **Polish diagnostics**: `polish.config` now records style, intensity, sampling temperature, and which safeguard layers reached the model, and `polish.skippedLLM` records locally short-circuited utterances. / **润色诊断**:`polish.config` 记录风格、强度、采样温度以及本次真正发给模型的防线层级,`polish.skippedLLM` 记录本地短路的语句。
|
|
||||||
|
|
||||||
## [1.6.6] - 2026-08-08
|
## [1.6.6] - 2026-08-08
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,66 @@
|
|||||||
"productID" : "ByRockyACoffee",
|
"productID" : "ByRockyACoffee",
|
||||||
"referenceName" : "给老刘买杯咖啡",
|
"referenceName" : "给老刘买杯咖啡",
|
||||||
"type" : "Consumable"
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "8",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "6789921327",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Adds 500 account credits for managed speech recognition and AI services.",
|
||||||
|
"displayName" : "500 OSG Credits",
|
||||||
|
"locale" : "en_US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description" : "为托管语音识别与 AI 服务增加 500 账号积分。",
|
||||||
|
"displayName" : "500 OSG 积分",
|
||||||
|
"locale" : "zh_Hans"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "500tks",
|
||||||
|
"referenceName" : "500 OSG 积分",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "18",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "6789921329",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Adds 1,500 account credits for managed speech recognition and AI services.",
|
||||||
|
"displayName" : "1,500 OSG Credits",
|
||||||
|
"locale" : "en_US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description" : "为托管语音识别与 AI 服务增加 1,500 账号积分。",
|
||||||
|
"displayName" : "1,500 OSG 积分",
|
||||||
|
"locale" : "zh_Hans"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "1500tks",
|
||||||
|
"referenceName" : "1,500 OSG 积分",
|
||||||
|
"type" : "Consumable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"displayPrice" : "28",
|
||||||
|
"familyShareable" : false,
|
||||||
|
"internalID" : "6789921328",
|
||||||
|
"localizations" : [
|
||||||
|
{
|
||||||
|
"description" : "Adds 3,000 account credits for managed speech recognition and AI services.",
|
||||||
|
"displayName" : "3,000 OSG Credits",
|
||||||
|
"locale" : "en_US"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description" : "为托管语音识别与 AI 服务增加 3,000 账号积分。",
|
||||||
|
"displayName" : "3,000 OSG 积分",
|
||||||
|
"locale" : "zh_Hans"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"productID" : "3000tks",
|
||||||
|
"referenceName" : "3,000 OSG 积分",
|
||||||
|
"type" : "Consumable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"settings" : {
|
"settings" : {
|
||||||
|
|||||||
@@ -102,6 +102,10 @@
|
|||||||
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition.</string>
|
<string>OSGKeyboard uses speech recognition to transcribe your voice. Audio is processed on-device by default, or sent to your configured speech provider only when you enable cloud recognition.</string>
|
||||||
|
<key>OSGPrivateKeychainAccessGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||||
|
<key>SKIncludeConsumableInAppPurchaseHistory</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -2,6 +2,16 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:osglab.com</string>
|
||||||
|
</array>
|
||||||
|
<key>com.apple.developer.devicecheck.appattest-environment</key>
|
||||||
|
<string>$(APP_ATTEST_ENVIRONMENT)</string>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
@@ -13,6 +23,7 @@
|
|||||||
<key>keychain-access-groups</key>
|
<key>keychain-access-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
||||||
|
<string>$(AppIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// OSGKeyboardApp.swift
|
// OSGKeyboardApp.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct OSGKeyboardApp: App {
|
struct OSGKeyboardApp: App {
|
||||||
@@ -39,6 +39,14 @@ struct OSGKeyboardApp: App {
|
|||||||
AIKeyboardDemoView()
|
AIKeyboardDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
||||||
AIClipboardSkillLayoutDemoView()
|
AIClipboardSkillLayoutDemoView()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
|
||||||
|
AssistantKeyboardUITestHarness()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--account-ui-test") {
|
||||||
|
AccountCenterUITestHarness()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--managed-consent-ui-test") {
|
||||||
|
ManagedCloudConsentUITestHarness()
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
|
||||||
|
FlowPiPDeviceUITestHarness()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||||
ClipboardHistoryDemoView()
|
ClipboardHistoryDemoView()
|
||||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
||||||
@@ -46,6 +54,11 @@ struct OSGKeyboardApp: App {
|
|||||||
EditPagerUITestHarness()
|
EditPagerUITestHarness()
|
||||||
}
|
}
|
||||||
.preferredColorScheme(appearance.colorScheme)
|
.preferredColorScheme(appearance.colorScheme)
|
||||||
|
} else if ProcessInfo.processInfo.arguments.contains("--iap-review-screenshot") {
|
||||||
|
ThemedRoot {
|
||||||
|
IAPReviewScreenshotHarness()
|
||||||
|
}
|
||||||
|
.preferredColorScheme(.light)
|
||||||
} else if AppGroup.isAvailable {
|
} else if AppGroup.isAvailable {
|
||||||
ThemedRoot {
|
ThemedRoot {
|
||||||
MainAppRoot()
|
MainAppRoot()
|
||||||
@@ -95,7 +108,7 @@ struct OSGKeyboardApp: App {
|
|||||||
forKey: AppGroupConfiguration.Keys.uiLanguage
|
forKey: AppGroupConfiguration.Keys.uiLanguage
|
||||||
)
|
)
|
||||||
// Clipboard demo needs history + suggestion strip flags on.
|
// Clipboard demo needs history + suggestion strip flags on.
|
||||||
if scenario == .clipboard {
|
if scenario == .clipboard || scenario == .clipboardSkills {
|
||||||
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
|
defaults.set(true, forKey: AppGroupConfiguration.Keys.clipboardHistoryEnabled)
|
||||||
defaults.set(
|
defaults.set(
|
||||||
true,
|
true,
|
||||||
@@ -137,8 +150,7 @@ struct OSGKeyboardApp: App {
|
|||||||
return WhatsNewDemoScenario(rawValue: raw)
|
return WhatsNewDemoScenario(rawValue: raw)
|
||||||
}
|
}
|
||||||
if let idx = args.firstIndex(of: "--whats-new-scenario"),
|
if let idx = args.firstIndex(of: "--whats-new-scenario"),
|
||||||
args.index(after: idx) < args.endIndex
|
args.index(after: idx) < args.endIndex {
|
||||||
{
|
|
||||||
return WhatsNewDemoScenario(rawValue: args[args.index(after: idx)])
|
return WhatsNewDemoScenario(rawValue: args[args.index(after: idx)])
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -150,8 +162,7 @@ struct OSGKeyboardApp: App {
|
|||||||
return WhatsNewDemoScenario.Language(rawValue: raw) ?? .zh
|
return WhatsNewDemoScenario.Language(rawValue: raw) ?? .zh
|
||||||
}
|
}
|
||||||
if let idx = args.firstIndex(of: "--whats-new-lang"),
|
if let idx = args.firstIndex(of: "--whats-new-lang"),
|
||||||
args.index(after: idx) < args.endIndex
|
args.index(after: idx) < args.endIndex {
|
||||||
{
|
|
||||||
return WhatsNewDemoScenario.Language(
|
return WhatsNewDemoScenario.Language(
|
||||||
rawValue: args[args.index(after: idx)]
|
rawValue: args[args.index(after: idx)]
|
||||||
) ?? .zh
|
) ?? .zh
|
||||||
@@ -176,6 +187,10 @@ struct OSGKeyboardApp: App {
|
|||||||
return "待办:"
|
return "待办:"
|
||||||
case (.clipboard, .en):
|
case (.clipboard, .en):
|
||||||
return "Todo: "
|
return "Todo: "
|
||||||
|
case (.clipboardSkills, .zh):
|
||||||
|
return "待办:"
|
||||||
|
case (.clipboardSkills, .en):
|
||||||
|
return "Todo: "
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -9,9 +9,8 @@
|
|||||||
<key>NSPrivacyCollectedDataTypes</key>
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<!-- Voice recordings: processed on-device by default; uploaded to
|
<!-- Voice recordings: processed on-device by default; uploaded only
|
||||||
the user's configured ASR provider only when the cloud engine
|
after the user selects a configured or managed cloud engine. -->
|
||||||
is explicitly enabled (opt-in with acknowledgment). -->
|
|
||||||
<key>NSPrivacyCollectedDataType</key>
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
@@ -35,6 +34,45 @@
|
|||||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- Optional display name supplied by Sign in with Apple. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeName</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- StoreKit transaction records used for credit verification. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurchaseHistory</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<!-- Pseudonymous account and managed-grant identifiers. -->
|
||||||
|
<key>NSPrivacyCollectedDataType</key>
|
||||||
|
<string>NSPrivacyCollectedDataTypeUserID</string>
|
||||||
|
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||||
|
<array>
|
||||||
|
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>NSPrivacyAccessedAPITypes</key>
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
Binary file not shown.
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"bin_bytes" : 41456,
|
"bin_bytes" : 198494,
|
||||||
"bin_file" : "OSGKeyboardCLM.bin",
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
"export_seconds" : 0.030717015266418457,
|
"export_seconds" : 0.035165071487426758,
|
||||||
"generated_at" : "2026-08-14T15:07:23Z",
|
"generated_at" : "2026-07-06T13:16:27Z",
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
"locale" : "zh_CN",
|
"locale" : "zh_CN",
|
||||||
"phrase_count" : 3040,
|
"phrase_count" : 13329,
|
||||||
"sources" : {
|
"sources" : {
|
||||||
"ai_tech_seed" : 3040
|
"ai_tech_seed" : 3040,
|
||||||
|
"computer_terms" : 10300
|
||||||
},
|
},
|
||||||
"version" : "1.0.1"
|
"version" : "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -1,22 +1,22 @@
|
|||||||
{
|
{
|
||||||
"entry_count" : 3040,
|
"version": "v1",
|
||||||
"files" : {
|
"generated_at": "2026-07-05T11:27:48.823095+00:00",
|
||||||
"phrases" : "phrases.tsv"
|
"locale": "zh-Hans",
|
||||||
},
|
"entry_count": 10300,
|
||||||
"generated_at" : "2026-08-14T15:07:23Z",
|
"sources": [
|
||||||
"locale" : "zh-Hans",
|
|
||||||
"notes" : [
|
|
||||||
"Project-curated bilingual AI brands, technology terms, companies, and names.",
|
|
||||||
"No third-party cell dictionaries or Sogou-derived data.",
|
|
||||||
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies."
|
|
||||||
],
|
|
||||||
"sources" : [
|
|
||||||
{
|
{
|
||||||
"key" : "ai_tech_seed",
|
"key": "computer_terms",
|
||||||
"label" : "OSGKeyboard curated AI\/tech lexicon",
|
"label": "计算机词汇大全【官方推荐】",
|
||||||
"license" : "MIT (curated seed; OSGKeyboard contributors)",
|
"weight": 5,
|
||||||
"raw_count" : 3040
|
"raw_count": 10300
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"version" : "v1"
|
"notes": [
|
||||||
|
"Domain-specific computer/IT vocabulary only; casual network slang and Sogou popular words removed.",
|
||||||
|
"PhraseCount weights map to SFCustomLanguageModelData relative frequencies.",
|
||||||
|
"Merged with ai-tech-brands seed at export time for the final .bin asset."
|
||||||
|
],
|
||||||
|
"files": {
|
||||||
|
"phrases": "phrases.tsv"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -1,13 +1,14 @@
|
|||||||
{
|
{
|
||||||
"bin_bytes" : 41456,
|
"bin_bytes" : 198494,
|
||||||
"bin_file" : "OSGKeyboardCLM.bin",
|
"bin_file" : "OSGKeyboardCLM.bin",
|
||||||
"export_seconds" : 0.030717015266418457,
|
"export_seconds" : 0.035165071487426758,
|
||||||
"generated_at" : "2026-08-14T15:07:23Z",
|
"generated_at" : "2026-07-06T13:16:27Z",
|
||||||
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
"identifier" : "com.osgkeyboard.custom-lm.v1",
|
||||||
"locale" : "zh_CN",
|
"locale" : "zh_CN",
|
||||||
"phrase_count" : 3040,
|
"phrase_count" : 13329,
|
||||||
"sources" : {
|
"sources" : {
|
||||||
"ai_tech_seed" : 3040
|
"ai_tech_seed" : 3040,
|
||||||
|
"computer_terms" : 10300
|
||||||
},
|
},
|
||||||
"version" : "1.0.1"
|
"version" : "1.0.0"
|
||||||
}
|
}
|
||||||
@@ -22,17 +22,18 @@
|
|||||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||||
<div id="top">
|
<div id="top">
|
||||||
<h1>OSGKeyboard Privacy Policy</h1>
|
<h1>OSGKeyboard Privacy Policy</h1>
|
||||||
<p><strong>Last updated:</strong> August 14, 2026 · <em>v1.3</em></p>
|
<p><strong>Last updated:</strong> August 19, 2026 · <em>v2.0</em></p>
|
||||||
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. You may instead choose a cloud recognition engine in Settings. After transcription, text may be polished, translated, or used by AI Agent features through a provider you configure.</p>
|
<p>OSGKeyboard provides an iOS custom keyboard and a macOS menu-bar dictation app. iOS uses Apple's on-device speech APIs by default; macOS can use a downloaded local Qwen3-ASR model. The core local and user-configured-provider features do not require an OSGKeyboard account. On iOS, you may optionally sign in with Apple and choose managed credits instead of supplying provider credentials. This policy explains both paths.</p>
|
||||||
|
|
||||||
<h2>What we collect</h2>
|
<h2>What we collect</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. If you actively select a cloud engine in Settings, recordings are sent directly to the ASR provider you configure; that provider’s privacy policy applies. OSGKeyboard does not store or proxy audio on its own servers.</li>
|
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. With a user-configured cloud engine, recordings are sent directly to that ASR provider. If you explicitly choose managed credits, recordings are sent through the OSGKeyboard managed gateway at <code>account.osglab.com</code> to the managed ASR provider for transcription. OSGKeyboard does not keep raw recordings after the request is processed.</li>
|
||||||
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you have configured an LLM API key. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. Without an API key, raw ASR text is inserted.</li>
|
<li><strong>Transcribed text and cursor context</strong> — after ASR, the transcript (not audio) may be sent for polish when you use a configured LLM or managed credits. A small amount of text around the cursor may be included to continue naturally. Secure fields are never captured, and cursor context is not written to logs or voice history. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>.</li>
|
||||||
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the same configured LLM provider to generate an answer. When that provider supports server-side web search, the provider may retrieve public web results to answer time-sensitive questions. Search queries and retrieved snippets are processed by that provider under its own privacy policy; OSGKeyboard does not operate a search index or proxy search traffic.</li>
|
<li><strong>AI mode questions</strong> — in AI keyboard mode, your spoken question text—or a question you tap from an idle suggestion—is sent to the selected AI service to generate an answer. User-configured requests go directly to that provider; managed-credit requests go through <code>account.osglab.com</code>. When the selected service supports server-side web search, it may retrieve public web results for time-sensitive answers.</li>
|
||||||
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
|
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
|
||||||
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
|
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
|
||||||
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
<li><strong>API credentials</strong> — stored in the iOS Keychain and shared between the main app and keyboard extension. When iCloud settings sync is enabled, keys replicate through iCloud Keychain (not iCloud KVS JSON).</li>
|
||||||
|
<li><strong>Optional OSGKeyboard account and purchases</strong> — no account is required for local transcription or user-configured providers. If you choose Sign in with Apple, the account service processes the Apple identity token, a pseudonymous OSGKeyboard account identifier, optional display name, device-integrity evidence, credit balance, referral state, and StoreKit transaction identifiers needed to verify consumable credit purchases. Account session tokens stay in the main app's private Keychain; the keyboard extension receives only a short-lived, scope-limited managed-service grant. You can sign out or delete the account in the app.</li>
|
||||||
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults. Optional iCloud sync mirrors eligible preferences, usage statistics, and voice history through your private iCloud account. Clipboard-history consent and its suggestion-strip switch stay device-local and are not activated by iCloud settings sync.</li>
|
||||||
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
<li><strong>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
||||||
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
||||||
@@ -55,13 +56,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>Third parties</h2>
|
<h2>Third parties</h2>
|
||||||
<p>Cloud ASR and LLM requests go directly to the provider you configure. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each third party's privacy policy applies; OSGKeyboard does not proxy, log, or aggregate provider requests.</p>
|
<p>User-configured cloud ASR and LLM requests go directly to the provider you configure. Managed-credit requests go through <code>account.osglab.com</code> to the managed ASR or LLM provider. Apple processes Sign in with Apple and StoreKit purchases. AI skills may hand generated data to Apple Shortcuts or open a route in Apple Maps, Amap, or Baidu Maps. Public suggestions and updates use <code>key.osglab.com</code> and <code>download.osglab.com</code>. Optional Mac models are downloaded from Hugging Face. Each service's privacy policy applies.</p>
|
||||||
|
|
||||||
<h2>Data retention</h2>
|
<h2>Data retention</h2>
|
||||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
||||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
||||||
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
||||||
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
||||||
|
<p><strong>Optional account</strong> — account profile, credit ledger, StoreKit audit records, and limited anti-abuse records are retained by the account service while the account is active. Account deletion removes the active profile and credentials; pseudonymous ledger, purchase-audit, and time-limited anti-abuse records may remain where required to prevent replay, fraud, or abuse.</p>
|
||||||
|
|
||||||
<h2>Contact</h2>
|
<h2>Contact</h2>
|
||||||
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
|
||||||
@@ -69,17 +71,18 @@
|
|||||||
|
|
||||||
<hr id="zh">
|
<hr id="zh">
|
||||||
<h1>OSGKeyboard 隐私政策</h1>
|
<h1>OSGKeyboard 隐私政策</h1>
|
||||||
<p><strong>更新日期:</strong>2026 年 8 月 14 日 · <em>v1.3</em></p>
|
<p><strong>更新日期:</strong>2026 年 8 月 19 日 · <em>v2.0</em></p>
|
||||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。你也可以在设置中主动选择云端识别引擎。转写后,文字可能通过你配置的服务商用于润色、翻译或 AI Agent 功能。</p>
|
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
|
||||||
|
|
||||||
<h2>我们处理的数据</h2>
|
<h2>我们处理的数据</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。若你在设置中主动选择云端引擎,录音会直接发送到你配置的识别服务商。OSGKeyboard 自身不存储或中转音频。</li>
|
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
|
||||||
<li><strong>转写文字与光标上下文</strong> — 配置 LLM API Key 后,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。</li>
|
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</li>
|
||||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
|
||||||
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
|
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
|
||||||
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
||||||
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
<li><strong>API 凭证</strong> — 保存在设备 Keychain,在主 App 与键盘扩展间共享。开启 iCloud 设置同步后,经 iCloud 钥匙串同步(非 iCloud KVS JSON)。</li>
|
||||||
|
<li><strong>可选 OSGKeyboard 账号与购买</strong> — 本地转写和用户自备服务商无需账号。若你选择 Apple 登录,账号服务会处理 Apple 身份令牌、匿名化的 OSGKeyboard 账号标识、可选昵称、设备完整性证明、积分余额、邀请状态,以及核验消耗型积分包所需的 StoreKit 交易标识。账号会话令牌只保存在主 App 私有 Keychain;键盘扩展仅获得短时、限权的托管服务凭证。你可以在 App 内退出或注销账号。</li>
|
||||||
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在 App Group。可选 iCloud 同步经私有 iCloud 账户镜像可同步的偏好、统计与语音历史。剪贴板历史采集许可与建议条开关仅属于本机,不会被 iCloud 设置同步开启。</li>
|
||||||
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
<li><strong>可选剪贴板历史</strong> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||||
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||||
@@ -102,13 +105,14 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>第三方</h2>
|
<h2>第三方</h2>
|
||||||
<p>云端 ASR 与 LLM 请求直接发送到你配置的服务商。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各第三方的隐私政策适用。</p>
|
<p>用户自备的云端 ASR 与 LLM 请求直接发送到你配置的服务商;托管积分请求经 <code>account.osglab.com</code> 发送到托管 ASR 或 LLM 服务商。Apple 处理 Apple 登录与 StoreKit 购买。AI 技能也可能把生成数据交给 Apple 快捷指令,或在 Apple 地图、高德地图、百度地图中打开路线。公开建议与更新页面分别由 <code>key.osglab.com</code>、<code>download.osglab.com</code> 提供;可选 Mac 模型从 Hugging Face 下载。各服务的隐私政策适用。</p>
|
||||||
|
|
||||||
<h2>数据保留</h2>
|
<h2>数据保留</h2>
|
||||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
||||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||||
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
||||||
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||||
|
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||||
|
|
||||||
<h2>联系</h2>
|
<h2>联系</h2>
|
||||||
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// opens the Add sheet in Shortcuts directly.
|
// opens the Add sheet in Shortcuts directly.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum AIAgentShortcutInstaller {
|
enum AIAgentShortcutInstaller {
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Consumes the keyboard's pending export-skill payload. Navigate opens a
|
// Consumes the keyboard's pending export-skill payload. Navigate opens a
|
||||||
// map URL in the host. Other exports open the companion Shortcut.
|
// map URL in the host. Other exports open the companion Shortcut.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum AIAgentShortcutRunner {
|
enum AIAgentShortcutRunner {
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ enum AppPermissions {
|
|||||||
case restricted
|
case restricted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum PasteAccessResult: Equatable {
|
||||||
|
case verified
|
||||||
|
case noTextAvailable
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let pasteAccessVerifiedKey = "clipboard.pasteAccessVerified.v1"
|
||||||
|
|
||||||
static var micStatus: MicStatus {
|
static var micStatus: MicStatus {
|
||||||
switch AVAudioApplication.shared.recordPermission {
|
switch AVAudioApplication.shared.recordPermission {
|
||||||
case .granted: return .granted
|
case .granted: return .granted
|
||||||
@@ -46,6 +54,12 @@ enum AppPermissions {
|
|||||||
micStatus == .granted && speechStatus == .granted
|
micStatus == .granted && speechStatus == .granted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// iOS does not expose the current "Paste from Other Apps" setting.
|
||||||
|
/// This records the last explicit, successful user-initiated read instead.
|
||||||
|
static var hasVerifiedPasteAccess: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: pasteAccessVerifiedKey)
|
||||||
|
}
|
||||||
|
|
||||||
/// Show guided permission pages when any Flow permission is not granted.
|
/// Show guided permission pages when any Flow permission is not granted.
|
||||||
static var needsPermissionGuidance: Bool {
|
static var needsPermissionGuidance: Bool {
|
||||||
micStatus != .granted || speechStatus != .granted
|
micStatus != .granted || speechStatus != .granted
|
||||||
@@ -82,10 +96,15 @@ enum AppPermissions {
|
|||||||
/// and create the app's "Paste from Other Apps" settings entry.
|
/// and create the app's "Paste from Other Apps" settings entry.
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func requestPasteAccess() -> Bool {
|
static func requestPasteAccess() -> PasteAccessResult {
|
||||||
let pasteboard = UIPasteboard.general
|
let pasteboard = UIPasteboard.general
|
||||||
guard pasteboard.hasStrings else { return false }
|
guard pasteboard.hasStrings else { return .noTextAvailable }
|
||||||
return pasteboard.string != nil
|
guard pasteboard.string != nil else {
|
||||||
|
UserDefaults.standard.set(false, forKey: pasteAccessVerifiedKey)
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
UserDefaults.standard.set(true, forKey: pasteAccessVerifiedKey)
|
||||||
|
return .verified
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Home-screen guidance when Flow permissions are missing after onboarding.
|
/// Home-screen guidance when Flow permissions are missing after onboarding.
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a
|
// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a
|
||||||
// scene delegate — SwiftUI's `.onOpenURL` does not expose it.
|
// scene delegate — SwiftUI's `.onOpenURL` does not expose it.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Buffers launch/open URLs until the SwiftUI root registers a handler.
|
/// Buffers launch/open URLs until the SwiftUI root registers a handler.
|
||||||
///
|
///
|
||||||
@@ -77,6 +77,7 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
options connectionOptions: UIScene.ConnectionOptions
|
options connectionOptions: UIScene.ConnectionOptions
|
||||||
) {
|
) {
|
||||||
handle(connectionOptions.urlContexts)
|
handle(connectionOptions.urlContexts)
|
||||||
|
handle(connectionOptions.userActivities)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warm open while the app is already running or suspended in memory.
|
/// Warm open while the app is already running or suspended in memory.
|
||||||
@@ -84,6 +85,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
handle(URLContexts)
|
handle(URLContexts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Universal Links arrive as browsing-web user activities, not URL contexts.
|
||||||
|
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||||
|
handle([userActivity])
|
||||||
|
}
|
||||||
|
|
||||||
private func handle(_ contexts: Set<UIOpenURLContext>) {
|
private func handle(_ contexts: Set<UIOpenURLContext>) {
|
||||||
// Extract Sendable primitives up front so we never hop a
|
// Extract Sendable primitives up front so we never hop a
|
||||||
// non-Sendable `UIOpenURLContext` across the actor boundary.
|
// non-Sendable `UIOpenURLContext` across the actor boundary.
|
||||||
@@ -108,4 +114,17 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handle(_ activities: Set<NSUserActivity>) {
|
||||||
|
let urls = activities.compactMap { activity -> URL? in
|
||||||
|
guard activity.activityType == NSUserActivityTypeBrowsingWeb else { return nil }
|
||||||
|
return activity.webpageURL
|
||||||
|
}
|
||||||
|
guard !urls.isEmpty else { return }
|
||||||
|
|
||||||
|
// Scene delegate callbacks are delivered on the main thread.
|
||||||
|
MainActor.assumeIsolated {
|
||||||
|
urls.forEach { AppOpenURLRouter.shared.route($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import AVKit
|
import AVKit
|
||||||
import CoreMedia
|
import CoreMedia
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Why `startAndWait` could not prove an active PiP window.
|
/// Why `startAndWait` could not prove an active PiP window.
|
||||||
enum FlowPiPStartFailure: Equatable, Sendable {
|
enum FlowPiPStartFailure: Equatable, Sendable {
|
||||||
@@ -414,7 +414,7 @@ final class FlowPictureInPictureController: NSObject {
|
|||||||
let attrs: [String: Any] = [
|
let attrs: [String: Any] = [
|
||||||
kCVPixelBufferCGImageCompatibilityKey as String: true,
|
kCVPixelBufferCGImageCompatibilityKey as String: true,
|
||||||
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
|
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
|
||||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any],
|
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any]
|
||||||
]
|
]
|
||||||
let status = CVPixelBufferCreate(
|
let status = CVPixelBufferCreate(
|
||||||
kCFAllocatorDefault,
|
kCFAllocatorDefault,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
// `.playAndRecord` capture for the whole session, utterance gating for
|
// `.playAndRecord` capture for the whole session, utterance gating for
|
||||||
// ASR and cloud LLM polish, with App Group result delivery.
|
// ASR and cloud LLM polish, with App Group result delivery.
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Speech
|
import Foundation
|
||||||
import OSGKeyboardShared
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import Speech
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct FlowUtteranceStartToken: Equatable, Sendable {
|
struct FlowUtteranceStartToken: Equatable, Sendable {
|
||||||
let generation: UInt64
|
let generation: UInt64
|
||||||
@@ -33,6 +33,13 @@ enum FlowUtteranceLifecyclePolicy {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class FlowSessionManager: ObservableObject {
|
final class FlowSessionManager: ObservableObject {
|
||||||
|
private struct PrefilledAIRequest {
|
||||||
|
let question: String
|
||||||
|
let conversationID: UUID?
|
||||||
|
let taskKind: ManagedGatewayTaskKind
|
||||||
|
let thinkingEnabled: Bool
|
||||||
|
}
|
||||||
|
|
||||||
@Published private(set) var isActive = false
|
@Published private(set) var isActive = false
|
||||||
@Published private(set) var isStarting = false
|
@Published private(set) var isStarting = false
|
||||||
@Published private(set) var sessionExpiresAt: Date?
|
@Published private(set) var sessionExpiresAt: Date?
|
||||||
@@ -126,7 +133,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
private var isColdStartHandoff = false
|
private var isColdStartHandoff = false
|
||||||
private var coldStartRecoveryTask: Task<Void, Never>?
|
private var coldStartRecoveryTask: Task<Void, Never>?
|
||||||
var shouldDeferHostHeavyWork: Bool {
|
var shouldDeferHostHeavyWork: Bool {
|
||||||
startingUtteranceId != nil
|
isStarting
|
||||||
|
|| startingUtteranceId != nil
|
||||||
|| isUtteranceRecording
|
|| isUtteranceRecording
|
||||||
|| isUtteranceProcessing
|
|| isUtteranceProcessing
|
||||||
|| hasUnacknowledgedTerminalResult()
|
|| hasUnacknowledgedTerminalResult()
|
||||||
@@ -922,6 +930,11 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
coldStartRecoveryTask?.cancel()
|
coldStartRecoveryTask?.cancel()
|
||||||
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
// The failed controller has been fully torn down. Give the app
|
||||||
|
// scene and Pegasus service a short settling interval before the
|
||||||
|
// one bounded cold-start retry creates a new controller generation.
|
||||||
|
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||||
|
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||||
let outcome = await self.pipController.startAndWait()
|
let outcome = await self.pipController.startAndWait()
|
||||||
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
|
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
|
||||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||||
@@ -1293,7 +1306,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
cancelAudioPrime(command)
|
cancelAudioPrime(command)
|
||||||
case .endAIConversation:
|
case .endAIConversation:
|
||||||
if currentUtteranceMode == .aiQuestion,
|
if currentUtteranceMode == .aiQuestion,
|
||||||
(isUtteranceProcessing || isUtteranceRecording) {
|
isUtteranceProcessing || isUtteranceRecording {
|
||||||
abortUtterance()
|
abortUtterance()
|
||||||
}
|
}
|
||||||
if let conversationID = command.aiConversationID {
|
if let conversationID = command.aiConversationID {
|
||||||
@@ -1358,15 +1371,19 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let utteranceId = command.utteranceId
|
let utteranceId = command.utteranceId
|
||||||
let commandSeq = command.commandSeq
|
let commandSeq = command.commandSeq
|
||||||
let sessionId = command.sessionId
|
let sessionId = command.sessionId
|
||||||
|
let taskKind = command.aiTaskKind ?? .aiQuestion
|
||||||
let thinkingEnabled = command.aiThinkingEnabled ?? true
|
let thinkingEnabled = command.aiThinkingEnabled ?? true
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
await self?.answerPrefilledAIQuestion(
|
await self?.answerPrefilledAIQuestion(
|
||||||
question: question,
|
request: PrefilledAIRequest(
|
||||||
conversationID: conversationID,
|
question: question,
|
||||||
|
conversationID: conversationID,
|
||||||
|
taskKind: taskKind,
|
||||||
|
thinkingEnabled: thinkingEnabled
|
||||||
|
),
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
utteranceId: utteranceId,
|
utteranceId: utteranceId,
|
||||||
commandSeq: commandSeq,
|
commandSeq: commandSeq
|
||||||
thinkingEnabled: thinkingEnabled
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1383,12 +1400,10 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
/// Skip ASR and answer a prefilled AI hint / typed question.
|
/// Skip ASR and answer a prefilled AI hint / typed question.
|
||||||
private func answerPrefilledAIQuestion(
|
private func answerPrefilledAIQuestion(
|
||||||
question: String,
|
request: PrefilledAIRequest,
|
||||||
conversationID: UUID?,
|
|
||||||
sessionId: UUID,
|
sessionId: UUID,
|
||||||
utteranceId: UUID,
|
utteranceId: UUID,
|
||||||
commandSeq: Int64,
|
commandSeq: Int64
|
||||||
thinkingEnabled: Bool
|
|
||||||
) async {
|
) async {
|
||||||
// Hint-card / prefilled questions never go through `finalizeUtterance`,
|
// Hint-card / prefilled questions never go through `finalizeUtterance`,
|
||||||
// so this path must drop the processing gate itself. Leaving it set
|
// so this path must drop the processing gate itself. Leaving it set
|
||||||
@@ -1399,7 +1414,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
utteranceId: utteranceId
|
utteranceId: utteranceId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
guard let conversationID else {
|
guard let conversationID = request.conversationID else {
|
||||||
claimAndStoreTerminal(utteranceId: utteranceId) {
|
claimAndStoreTerminal(utteranceId: utteranceId) {
|
||||||
storeFinalizedError(
|
storeFinalizedError(
|
||||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||||
@@ -1419,11 +1434,12 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
let service = try AIQuestionService.configured(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations,
|
conversations: aiConversations,
|
||||||
thinkingEnabled: thinkingEnabled
|
taskKind: request.taskKind,
|
||||||
|
thinkingEnabled: request.thinkingEnabled
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let answer = try await service.answer(
|
let answer = try await service.answer(
|
||||||
question: question,
|
question: request.question,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
targetLocaleID: pipelineStore.translationTargetLocaleId
|
||||||
) { [weak self] partial in
|
) { [weak self] partial in
|
||||||
@@ -1449,7 +1465,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
force: true
|
force: true
|
||||||
)
|
)
|
||||||
await service.commitSuccessfulTurn(
|
await service.commitSuccessfulTurn(
|
||||||
question: question,
|
question: request.question,
|
||||||
answer: answer,
|
answer: answer,
|
||||||
conversationID: conversationID
|
conversationID: conversationID
|
||||||
)
|
)
|
||||||
@@ -1466,7 +1482,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
claimAndStoreTerminal(utteranceId: utteranceId) {
|
claimAndStoreTerminal(utteranceId: utteranceId) {
|
||||||
storeFinalizedError(
|
storeFinalizedError(
|
||||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
Self.aiQuestionFailureMessage(for: error),
|
||||||
kind: .generic,
|
kind: .generic,
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
utteranceId: utteranceId,
|
utteranceId: utteranceId,
|
||||||
@@ -1844,9 +1860,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
let locale = SpeechLocaleResolver.resolve(localeId)
|
let locale = SpeechLocaleResolver.resolve(localeId)
|
||||||
let stream = capture.beginUtterance()
|
let stream = capture.beginUtterance()
|
||||||
|
let cloudService = asr as? CloudASRService
|
||||||
let useStreaming =
|
let useStreaming =
|
||||||
store.engineMode == "cloud"
|
store.engineMode == "cloud"
|
||||||
&& CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
&& (
|
||||||
|
cloudService?.supportsUtteranceStreaming
|
||||||
|
?? CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
||||||
|
)
|
||||||
let pipeline: ChunkedUtterancePipeline?
|
let pipeline: ChunkedUtterancePipeline?
|
||||||
if useStreaming {
|
if useStreaming {
|
||||||
chunkedPipeline = nil
|
chunkedPipeline = nil
|
||||||
@@ -1880,7 +1900,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
"max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
|
||||||
)
|
)
|
||||||
|
|
||||||
let cloudASRForStreaming = useStreaming ? (asr as? CloudASRService) : nil
|
let cloudASRForStreaming = useStreaming ? cloudService : nil
|
||||||
|
|
||||||
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
asrTask = Task.detached(priority: .userInitiated) { [weak manager = self] in
|
||||||
let outcome: ChunkedUtterancePipelineOutcome
|
let outcome: ChunkedUtterancePipelineOutcome
|
||||||
@@ -2297,7 +2317,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
|
|
||||||
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
|
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
|
||||||
stitchedFinal: lastFinal,
|
stitchedFinal: lastFinal,
|
||||||
partialSnapshot: bestPartialSnapshot
|
partialSnapshot: bestPartialSnapshot,
|
||||||
|
recognitionFailed: asrFailureMessage != nil
|
||||||
)
|
)
|
||||||
FlowTrace.pipeline(
|
FlowTrace.pipeline(
|
||||||
"batchFallback.decision",
|
"batchFallback.decision",
|
||||||
@@ -2405,7 +2426,8 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let service = try AIQuestionService.configured(
|
let service = try AIQuestionService.configured(
|
||||||
store: pipelineStore,
|
store: pipelineStore,
|
||||||
conversations: aiConversations
|
conversations: aiConversations,
|
||||||
|
taskKind: .aiQuestion
|
||||||
)
|
)
|
||||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||||
let publishUtteranceId = finalizeUtteranceId
|
let publishUtteranceId = finalizeUtteranceId
|
||||||
@@ -2457,7 +2479,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
claimAndStoreTerminal(utteranceId: finalizeUtteranceId) {
|
claimAndStoreTerminal(utteranceId: finalizeUtteranceId) {
|
||||||
storeFinalizedError(
|
storeFinalizedError(
|
||||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
Self.aiQuestionFailureMessage(for: error),
|
||||||
kind: .generic,
|
kind: .generic,
|
||||||
sessionId: finalizeSessionId,
|
sessionId: finalizeSessionId,
|
||||||
utteranceId: finalizeUtteranceId,
|
utteranceId: finalizeUtteranceId,
|
||||||
@@ -2541,6 +2563,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
mode: polishMode,
|
mode: polishMode,
|
||||||
systemPrompt: instructionPrompt?.system,
|
systemPrompt: instructionPrompt?.system,
|
||||||
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
||||||
|
taskKind: isEditLastInput ? .editLastInput : nil,
|
||||||
context: isInstructionMode ? nil : polishContext,
|
context: isInstructionMode ? nil : polishContext,
|
||||||
timeoutLimit: processingDeadlineAt.map {
|
timeoutLimit: processingDeadlineAt.map {
|
||||||
max(0.1, $0 - Date().timeIntervalSince1970)
|
max(0.1, $0 - Date().timeIntervalSince1970)
|
||||||
@@ -3006,6 +3029,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
mode: PolishingService.PolishMode,
|
mode: PolishingService.PolishMode,
|
||||||
systemPrompt: String? = nil,
|
systemPrompt: String? = nil,
|
||||||
providerIdOverride: String?,
|
providerIdOverride: String?,
|
||||||
|
taskKind: ManagedGatewayTaskKind? = nil,
|
||||||
context: PolishContext?,
|
context: PolishContext?,
|
||||||
timeoutLimit: TimeInterval? = nil
|
timeoutLimit: TimeInterval? = nil
|
||||||
) async throws -> PolishingService.PolishOutcome {
|
) async throws -> PolishingService.PolishOutcome {
|
||||||
@@ -3017,6 +3041,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
mode: mode,
|
mode: mode,
|
||||||
systemPrompt: systemPrompt,
|
systemPrompt: systemPrompt,
|
||||||
providerIdOverride: providerIdOverride,
|
providerIdOverride: providerIdOverride,
|
||||||
|
taskKind: taskKind,
|
||||||
context: context
|
context: context
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -3053,7 +3078,7 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
guard let self else { break }
|
guard let self else { break }
|
||||||
if self.isActive,
|
if self.isActive,
|
||||||
!self.capture.engineIsLive,
|
!self.capture.engineIsLive,
|
||||||
(self.isUtteranceRecording || self.isUtteranceProcessing) {
|
self.isUtteranceRecording || self.isUtteranceProcessing {
|
||||||
await self.reactivateCaptureIfNeeded()
|
await self.reactivateCaptureIfNeeded()
|
||||||
}
|
}
|
||||||
FlowSessionBridge.writeHeartbeat()
|
FlowSessionBridge.writeHeartbeat()
|
||||||
@@ -3073,6 +3098,13 @@ final class FlowSessionManager: ObservableObject {
|
|||||||
+ "errorBytes=\(error.localizedDescription.utf8.count)"
|
+ "errorBytes=\(error.localizedDescription.utf8.count)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func aiQuestionFailureMessage(for error: Error) -> String {
|
||||||
|
if let managedError = error as? ManagedGatewayError {
|
||||||
|
return managedError.localizedDescription
|
||||||
|
}
|
||||||
|
return AppL10n.string("flow.error.aiQuestionFailed")
|
||||||
|
}
|
||||||
|
|
||||||
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
|
private func traceIgnoredCommand(reason: String, command: FlowCommand, detail: String) {
|
||||||
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
|
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
|
||||||
guard signature != lastIgnoredCommandSignature else { return }
|
guard signature != lastIgnoredCommandSignature else { return }
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
enum HostReturnService {
|
enum HostReturnService {
|
||||||
/// Attempts to return to the pending host app. Clears the pending bundle id on success.
|
/// Attempts to return to the pending host app. Clears the pending bundle id on success.
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ enum OpenSourceLicenseCatalog {
|
|||||||
url: URL(string: "https://huggingface.co/collections/mlx-community/qwen3-asr"),
|
url: URL(string: "https://huggingface.co/collections/mlx-community/qwen3-asr"),
|
||||||
licenseText: qwenModelApacheText,
|
licenseText: qwenModelApacheText,
|
||||||
platforms: [.macOS]
|
platforms: [.macOS]
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
static func entries(for platform: Platform) -> [Entry] {
|
static func entries(for platform: Platform) -> [Entry] {
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ enum ReleaseNotesStore {
|
|||||||
components.queryItems = [
|
components.queryItems = [
|
||||||
URLQueryItem(name: "v", value: version),
|
URLQueryItem(name: "v", value: version),
|
||||||
URLQueryItem(name: "lang", value: queryLanguage(for: language)),
|
URLQueryItem(name: "lang", value: queryLanguage(for: language)),
|
||||||
URLQueryItem(name: "theme", value: queryTheme(for: colorScheme)),
|
URLQueryItem(name: "theme", value: queryTheme(for: colorScheme))
|
||||||
]
|
]
|
||||||
return components.url
|
return components.url
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
// observable outcome to onboarding and Settings. The keyboard extension only
|
// observable outcome to onboarding and Settings. The keyboard extension only
|
||||||
// reads readiness and opens the already-built data.
|
// reads readiness and opens the already-built data.
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
import Combine
|
||||||
|
import Foundation
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ enum ASRLocaleLabels {
|
|||||||
"zh-Hant": "locale.zh-Hant",
|
"zh-Hant": "locale.zh-Hant",
|
||||||
"en-US": "locale.en-US",
|
"en-US": "locale.en-US",
|
||||||
"ja-JP": "locale.ja-JP",
|
"ja-JP": "locale.ja-JP",
|
||||||
"ko-KR": "locale.ko-KR",
|
"ko-KR": "locale.ko-KR"
|
||||||
]
|
]
|
||||||
|
|
||||||
static func displayName(for localeId: String, language: AppUILanguage) -> String {
|
static func displayName(for localeId: String, language: AppUILanguage) -> String {
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// the in-app UI language override (not only the system language).
|
// the in-app UI language override (not only the system language).
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum AppL10n {
|
enum AppL10n {
|
||||||
static func string(
|
static func string(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,26 @@
|
|||||||
// AIClipboardSkillLayoutDemoView.swift
|
// AIClipboardSkillLayoutDemoView.swift
|
||||||
// OSGKeyboard · Main App (DEBUG-only)
|
// OSGKeyboard · Main App (DEBUG-only)
|
||||||
//
|
//
|
||||||
// Interactive layout preview of AI Agent clipboard skills on the real
|
// Interactive layout preview and What's New recording host for AI Agent
|
||||||
// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
|
// clipboard skills on the real unified `AIKeyboardView`. Launch with
|
||||||
// `--skills-count=N` (1...8).
|
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
|
||||||
|
// `--whats-new-lang=zh|en`.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct AIClipboardSkillLayoutDemoView: View {
|
struct AIClipboardSkillLayoutDemoView: View {
|
||||||
|
@StateObject private var config = ProviderConfig.shared
|
||||||
@StateObject private var state = KeyboardState()
|
@StateObject private var state = KeyboardState()
|
||||||
@StateObject private var typing = TypingSessionController()
|
@StateObject private var typing = TypingSessionController()
|
||||||
@State private var count: Int = Self.initialCount
|
@State private var count: Int = Self.initialCount
|
||||||
|
|
||||||
|
init() {
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
controls
|
controls
|
||||||
@@ -26,10 +33,15 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
.background(Palette.light.background)
|
.background(Palette.light.background)
|
||||||
}
|
}
|
||||||
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
|
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
|
||||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
|
||||||
.preferredColorScheme(.light)
|
.preferredColorScheme(.light)
|
||||||
.onAppear { apply(count) }
|
.task { await runTimeline() }
|
||||||
.onChange(of: count) { _, newValue in apply(newValue) }
|
.onChange(of: count) { _, newValue in showSkills(newValue) }
|
||||||
|
.onDisappear {
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var controls: some View {
|
private var controls: some View {
|
||||||
@@ -70,19 +82,53 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
.padding(.bottom, 16)
|
.padding(.bottom, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func apply(_ count: Int) {
|
private func prepareState() {
|
||||||
let clamped = min(max(count, 1), 8)
|
config.uiLanguage = language == .en ? .english : .chinese
|
||||||
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
state.surface = .ai
|
state.surface = .voice
|
||||||
state.aiServiceAvailable = true
|
state.aiServiceAvailable = true
|
||||||
state.micDisabled = false
|
state.micDisabled = false
|
||||||
state.layoutWidth = 390
|
state.layoutWidth = 390
|
||||||
state.usesIPadLayoutMetrics = false
|
state.usesIPadLayoutMetrics = false
|
||||||
state.clipboardHistoryEnabled = true
|
state.clipboardHistoryEnabled = true
|
||||||
|
state.undoAvailable = true
|
||||||
|
state.editAvailable = true
|
||||||
|
state.clipboardSuggestionText = language == .en
|
||||||
|
? "Meeting at 3pm tomorrow"
|
||||||
|
: "明天下午三点开会"
|
||||||
|
state.skillTipText = language == .en
|
||||||
|
? "Copied text is ready"
|
||||||
|
: "复制内容已就绪"
|
||||||
|
state.aiSession.enter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showSkills(_ count: Int) {
|
||||||
|
let clamped = min(max(count, 1), 8)
|
||||||
|
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
||||||
state.enabledClipboardSkillIDs = Array(
|
state.enabledClipboardSkillIDs = Array(
|
||||||
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
||||||
)
|
)
|
||||||
state.aiSession.enter()
|
}
|
||||||
|
|
||||||
|
private func runTimeline() async {
|
||||||
|
prepareState()
|
||||||
|
try? await Task.sleep(for: .seconds(3))
|
||||||
|
state.clipboardSuggestionText = nil
|
||||||
|
state.skillTipText = nil
|
||||||
|
showSkills(count)
|
||||||
|
try? await Task.sleep(for: .seconds(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var language: WhatsNewDemoScenario.Language {
|
||||||
|
let prefix = "--whats-new-lang="
|
||||||
|
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||||
|
where: { $0.hasPrefix(prefix) }
|
||||||
|
) else {
|
||||||
|
return .zh
|
||||||
|
}
|
||||||
|
return WhatsNewDemoScenario.Language(
|
||||||
|
rawValue: String(argument.dropFirst(prefix.count))
|
||||||
|
) ?? .zh
|
||||||
}
|
}
|
||||||
|
|
||||||
private static var initialCount: Int {
|
private static var initialCount: Int {
|
||||||
@@ -127,7 +173,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
|||||||
descriptionKey: "skills.translate.description",
|
descriptionKey: "skills.translate.description",
|
||||||
kind: .transform,
|
kind: .transform,
|
||||||
isDefault: false
|
isDefault: false
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
extras = Array(extras.prefix(count - catalog.count))
|
extras = Array(extras.prefix(count - catalog.count))
|
||||||
return catalog + extras
|
return catalog + extras
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
// AIKeyboardDemoView.swift
|
// AIKeyboardDemoView.swift
|
||||||
// OSGKeyboard · Main App (DEBUG-only)
|
// OSGKeyboard · Main App (DEBUG-only)
|
||||||
//
|
//
|
||||||
// What's New 1.7.0 recording host. Uses the **real** AI Agent settings page
|
// What's New recording host. Uses the **real** AI Agent settings page and the
|
||||||
// and the **real** `AIKeyboardView` (compiled into the app target) driven by a
|
// **real** unified `AIKeyboardView` (compiled into the app target), driven by a
|
||||||
// scripted `KeyboardState` — no ASR / LLM. Launch with `--ai-demo`.
|
// scripted `KeyboardState` — no ASR / LLM. Launch with `--ai-demo` and optional
|
||||||
|
// `--whats-new-lang=zh|en`.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct AIKeyboardDemoView: View {
|
struct AIKeyboardDemoView: View {
|
||||||
private enum Scene: Equatable {
|
private enum Scene: Equatable {
|
||||||
@@ -15,10 +16,6 @@ struct AIKeyboardDemoView: View {
|
|||||||
case keyboard
|
case keyboard
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let question = "周末去哪儿玩比较合适?"
|
|
||||||
private static let answer =
|
|
||||||
"可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。若想轻松一点,选人少的湖边步道也很合适。"
|
|
||||||
|
|
||||||
@StateObject private var config = ProviderConfig.shared
|
@StateObject private var config = ProviderConfig.shared
|
||||||
@StateObject private var state = KeyboardState()
|
@StateObject private var state = KeyboardState()
|
||||||
@StateObject private var typing = TypingSessionController()
|
@StateObject private var typing = TypingSessionController()
|
||||||
@@ -26,6 +23,10 @@ struct AIKeyboardDemoView: View {
|
|||||||
@State private var scene: Scene = .settings
|
@State private var scene: Scene = .settings
|
||||||
@State private var levelTick = 0.35
|
@State private var levelTick = 0.35
|
||||||
|
|
||||||
|
init() {
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
||||||
@@ -58,15 +59,18 @@ struct AIKeyboardDemoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
|
||||||
.task { await runTimeline() }
|
.task { await runTimeline() }
|
||||||
|
.onDisappear {
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Scripted timeline (real view models)
|
// MARK: - Scripted timeline (real view models)
|
||||||
|
|
||||||
private func runTimeline() async {
|
private func runTimeline() async {
|
||||||
prepareKeyboardState()
|
prepareKeyboardState()
|
||||||
config.uiLanguage = .chinese
|
config.uiLanguage = language == .en ? .english : .chinese
|
||||||
config.aiResponseLength = .medium
|
config.aiResponseLength = .medium
|
||||||
|
|
||||||
try? await sleep(2.4)
|
try? await sleep(2.4)
|
||||||
@@ -86,16 +90,16 @@ struct AIKeyboardDemoView: View {
|
|||||||
try? await sleep(0.2)
|
try? await sleep(0.2)
|
||||||
levelTick = Double.random(in: 0.25...0.9)
|
levelTick = Double.random(in: 0.25...0.9)
|
||||||
state.level = levelTick
|
state.level = levelTick
|
||||||
state.aiSession.updateTranscript(Self.question, utteranceID: utteranceID)
|
state.aiSession.updateTranscript(question, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||||
try? await sleep(0.55)
|
try? await sleep(0.55)
|
||||||
state.aiSession.beginGenerating(question: Self.question, utteranceID: utteranceID)
|
state.aiSession.beginGenerating(question: question, utteranceID: utteranceID)
|
||||||
try? await sleep(0.7)
|
try? await sleep(0.7)
|
||||||
|
|
||||||
// Progressive draft so the real answer area updates like production.
|
// Progressive draft so the real answer area updates like production.
|
||||||
let chars = Array(Self.answer)
|
let chars = Array(answer)
|
||||||
var index = 0
|
var index = 0
|
||||||
let step = 4
|
let step = 4
|
||||||
while index < chars.count {
|
while index < chars.count {
|
||||||
@@ -106,8 +110,10 @@ struct AIKeyboardDemoView: View {
|
|||||||
)
|
)
|
||||||
try? await sleep(0.05)
|
try? await sleep(0.05)
|
||||||
}
|
}
|
||||||
state.aiSession.receiveAnswer(Self.answer, utteranceID: utteranceID)
|
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
|
||||||
try? await sleep(1.0)
|
// Hold the explicit-insert review state long enough for screen capture
|
||||||
|
// and for viewers to read the answer before the demo advances.
|
||||||
|
try? await sleep(3.0)
|
||||||
|
|
||||||
state.aiSession.markAnswerInserted(offersSend: true)
|
state.aiSession.markAnswerInserted(offersSend: true)
|
||||||
try? await sleep(1.1)
|
try? await sleep(1.1)
|
||||||
@@ -116,7 +122,7 @@ struct AIKeyboardDemoView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func prepareKeyboardState() {
|
private func prepareKeyboardState() {
|
||||||
state.surface = .ai
|
state.surface = .voice
|
||||||
state.aiServiceAvailable = true
|
state.aiServiceAvailable = true
|
||||||
state.micDisabled = false
|
state.micDisabled = false
|
||||||
state.layoutWidth = 390
|
state.layoutWidth = 390
|
||||||
@@ -125,6 +131,30 @@ struct AIKeyboardDemoView: View {
|
|||||||
state.aiSession.enter()
|
state.aiSession.enter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var language: WhatsNewDemoScenario.Language {
|
||||||
|
let prefix = "--whats-new-lang="
|
||||||
|
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||||
|
where: { $0.hasPrefix(prefix) }
|
||||||
|
) else {
|
||||||
|
return .zh
|
||||||
|
}
|
||||||
|
return WhatsNewDemoScenario.Language(
|
||||||
|
rawValue: String(argument.dropFirst(prefix.count))
|
||||||
|
) ?? .zh
|
||||||
|
}
|
||||||
|
|
||||||
|
private var question: String {
|
||||||
|
language == .en
|
||||||
|
? "Where should I go this weekend?"
|
||||||
|
: "周末去哪儿玩比较合适?"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var answer: String {
|
||||||
|
language == .en
|
||||||
|
? "Try a nearby town day trip: walk through a park or old street, stop at a café, then have a local dinner."
|
||||||
|
: "可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。"
|
||||||
|
}
|
||||||
|
|
||||||
/// Slow-mo for screenshot-sequence recording.
|
/// Slow-mo for screenshot-sequence recording.
|
||||||
private func sleep(_ seconds: Double) async throws {
|
private func sleep(_ seconds: Double) async throws {
|
||||||
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
|
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Editable fields for the three OpenAI-compatible config values:
|
// Editable fields for the three OpenAI-compatible config values:
|
||||||
// Base URL, API Key, Model.
|
// Base URL, API Key, Model.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct APISettingsCard: View {
|
struct APISettingsCard: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
//
|
//
|
||||||
// Cloud ASR credentials — independent from the polish LLM card.
|
// Cloud ASR credentials — independent from the polish LLM card.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ASRSettingsCard: View {
|
struct ASRSettingsCard: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// AccountAppleAuthorizationButton.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Sign in with Apple presentation and nonce handling shared by sign-in and
|
||||||
|
// deletion reauthentication. Tokens are passed directly to the auth boundary.
|
||||||
|
|
||||||
|
import AuthenticationServices
|
||||||
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum AccountAppleAuthorizationPurpose {
|
||||||
|
case signIn
|
||||||
|
case deleteAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountAppleAuthorizationButton: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||||
|
|
||||||
|
let purpose: AccountAppleAuthorizationPurpose
|
||||||
|
var onDeleted: () -> Void = {}
|
||||||
|
|
||||||
|
@State private var rawNonce: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SignInWithAppleButton(
|
||||||
|
purpose == .signIn ? .signIn : .continue,
|
||||||
|
onRequest: prepareRequest,
|
||||||
|
onCompletion: completeAuthorization
|
||||||
|
)
|
||||||
|
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
|
.accessibilityLabel(
|
||||||
|
purpose == .signIn
|
||||||
|
? Text("account.signIn.apple")
|
||||||
|
: Text("account.delete.reauthenticate")
|
||||||
|
)
|
||||||
|
.accessibilityHint(
|
||||||
|
purpose == .signIn
|
||||||
|
? Text("account.signIn.hint")
|
||||||
|
: Text("account.delete.reauthenticateHint")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
|
||||||
|
guard let nonce = AccountAppleNonce.make() else {
|
||||||
|
rawNonce = nil
|
||||||
|
request.nonce = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rawNonce = nonce.rawValue
|
||||||
|
request.requestedScopes = purpose == .signIn ? [.fullName] : []
|
||||||
|
request.nonce = nonce.sha256Hex
|
||||||
|
}
|
||||||
|
|
||||||
|
private func completeAuthorization(_ result: Result<ASAuthorization, Error>) {
|
||||||
|
guard let rawNonce else {
|
||||||
|
OSGDiag.log(
|
||||||
|
"appleAuthorization failed stage=nonce-unavailable",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
coordinator.recordAppleAuthorizationFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.rawNonce = nil
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case let .success(authorization):
|
||||||
|
guard let payload = AccountAppleAuthorizationPayload.make(
|
||||||
|
from: authorization,
|
||||||
|
rawNonce: rawNonce
|
||||||
|
) else {
|
||||||
|
OSGDiag.log(
|
||||||
|
"appleAuthorization failed stage=credential-payload",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
coordinator.recordAppleAuthorizationFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
switch purpose {
|
||||||
|
case .signIn:
|
||||||
|
await coordinator.signIn(with: payload)
|
||||||
|
if coordinator.isSignedIn {
|
||||||
|
ProviderConfig.shared.reloadFromPersistedStorage()
|
||||||
|
}
|
||||||
|
case .deleteAccount:
|
||||||
|
await coordinator.deleteAccount(with: payload)
|
||||||
|
if !coordinator.isSignedIn {
|
||||||
|
onDeleted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case let .failure(error):
|
||||||
|
let diagnostic = error as NSError
|
||||||
|
OSGDiag.log(
|
||||||
|
"appleAuthorization failed stage=system "
|
||||||
|
+ "domain=\(diagnostic.domain) code=\(diagnostic.code)",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
if (error as? ASAuthorizationError)?.code != .canceled {
|
||||||
|
coordinator.recordAppleAuthorizationFailure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccountAppleAuthorizationPayload {
|
||||||
|
static func make(
|
||||||
|
from authorization: ASAuthorization,
|
||||||
|
rawNonce: String
|
||||||
|
) -> AppleAuthorizationPayload? {
|
||||||
|
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
||||||
|
let identityTokenData = credential.identityToken,
|
||||||
|
let authorizationCodeData = credential.authorizationCode,
|
||||||
|
let identityToken = String(data: identityTokenData, encoding: .utf8),
|
||||||
|
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
|
||||||
|
!identityToken.isEmpty,
|
||||||
|
!authorizationCode.isEmpty
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppleAuthorizationPayload(
|
||||||
|
identityToken: identityToken,
|
||||||
|
authorizationCode: authorizationCode,
|
||||||
|
nonce: rawNonce,
|
||||||
|
displayName: displayName(from: credential.fullName)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func displayName(from components: PersonNameComponents?) -> String? {
|
||||||
|
guard let components else { return nil }
|
||||||
|
let value = PersonNameComponentsFormatter.localizedString(
|
||||||
|
from: components,
|
||||||
|
style: .default,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccountAppleNonce {
|
||||||
|
static func make() -> AppleSignInNonce? {
|
||||||
|
// Do not fall back to a merely unique identifier if the system RNG is
|
||||||
|
// unavailable. The completion path rejects authorization without a
|
||||||
|
// cryptographically random raw nonce.
|
||||||
|
return try? AppleSignInNonceGenerator().makeNonce()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
// AccountCenterUITestHarness.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Deterministic account and StoreKit UI integration harness. It exercises the
|
||||||
|
// production coordinator and views while keeping Apple and backend services
|
||||||
|
// outside XCUITest.
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct AccountCenterUITestHarness: View {
|
||||||
|
@StateObject private var coordinator: AccountSessionCoordinator
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let service = AccountCenterUITestService()
|
||||||
|
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||||
|
_coordinator = StateObject(
|
||||||
|
wrappedValue: AccountSessionCoordinator(
|
||||||
|
dependencies: AccountDependencies(
|
||||||
|
sessionService: service,
|
||||||
|
centerService: service
|
||||||
|
),
|
||||||
|
creditStore: store
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ThemedRoot {
|
||||||
|
NavigationStack {
|
||||||
|
AccountCenterView()
|
||||||
|
.environmentObject(coordinator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await coordinator.restoreIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
struct ManagedCloudConsentUITestHarness: View {
|
||||||
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
@StateObject private var coordinator: AccountSessionCoordinator
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let service = AccountCenterUITestService()
|
||||||
|
let configuration = ProviderConfig.shared
|
||||||
|
configuration.hasAcknowledgedCloudSharing = false
|
||||||
|
configuration.credentialSource = .byok
|
||||||
|
_coordinator = StateObject(
|
||||||
|
wrappedValue: AccountSessionCoordinator(
|
||||||
|
dependencies: AccountDependencies(
|
||||||
|
sessionService: service,
|
||||||
|
centerService: service
|
||||||
|
),
|
||||||
|
creditStore: AccountCenterUITestCreditStore(
|
||||||
|
accountID: service.account.accountID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ThemedRoot {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
EnginePickerSection(config: config)
|
||||||
|
.environmentObject(coordinator)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("settings.aiService.title")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await coordinator.restoreIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor AccountCenterUITestService: AccountSessionServicing, AccountCenterServicing {
|
||||||
|
nonisolated let account = AccountSession(
|
||||||
|
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!,
|
||||||
|
createdAtEpochSeconds: 1_700_000_000,
|
||||||
|
displayName: "UI Test Account"
|
||||||
|
)
|
||||||
|
|
||||||
|
func restoreSession() async throws -> AccountSession? {
|
||||||
|
account
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||||
|
_ = payload
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async throws {}
|
||||||
|
|
||||||
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||||
|
_ = payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||||
|
AccountCenterSnapshot(
|
||||||
|
account: account,
|
||||||
|
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
||||||
|
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||||
|
referrals: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||||
|
AccountSession(
|
||||||
|
accountID: account.accountID,
|
||||||
|
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createReferralCode() async throws -> String {
|
||||||
|
"UITestInvite_1234567890"
|
||||||
|
}
|
||||||
|
|
||||||
|
func redeemReferral(code: String) async throws {
|
||||||
|
_ = code
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||||
|
[
|
||||||
|
AccountCreditProduct(productID: "500tks", credits: 500),
|
||||||
|
AccountCreditProduct(productID: "1500tks", credits: 1_500),
|
||||||
|
AccountCreditProduct(productID: "3000tks", credits: 3_000)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||||
|
_ = signedTransaction
|
||||||
|
return AccountCreditPurchase(
|
||||||
|
transactionID: "2000000000200",
|
||||||
|
productID: "500tks",
|
||||||
|
creditsGranted: 500,
|
||||||
|
balanceAfter: 2_000,
|
||||||
|
replayed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditPurchaseHistory(
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||||
|
_ = limit
|
||||||
|
_ = cursor
|
||||||
|
return AccountCreditPurchaseHistoryPage(
|
||||||
|
items: [
|
||||||
|
AccountCreditPurchaseRecord(
|
||||||
|
transactionID: "2000000000199",
|
||||||
|
productID: "1500tks",
|
||||||
|
creditsGranted: 1_500,
|
||||||
|
balanceAfter: 1_500,
|
||||||
|
purchasedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
status: .credited
|
||||||
|
)
|
||||||
|
],
|
||||||
|
nextCursor: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class AccountCenterUITestCreditStore: AccountCreditStore {
|
||||||
|
private let accountID: UUID
|
||||||
|
private var transactions: [AccountStoreVerification]
|
||||||
|
|
||||||
|
init(accountID: UUID) {
|
||||||
|
self.accountID = accountID
|
||||||
|
transactions = [
|
||||||
|
.verified(
|
||||||
|
AccountStoreTransaction(
|
||||||
|
id: 2_000_000_000_199,
|
||||||
|
productID: "1500tks",
|
||||||
|
appAccountToken: accountID,
|
||||||
|
purchaseDate: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
signedTransaction: String(repeating: "h", count: 100),
|
||||||
|
finishOperation: {}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||||
|
let price: String
|
||||||
|
switch productID {
|
||||||
|
case "500tks":
|
||||||
|
price = "$0.99"
|
||||||
|
case "1500tks":
|
||||||
|
price = "$1.99"
|
||||||
|
case "3000tks":
|
||||||
|
price = "$2.99"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return AccountStoreProduct(id: productID, displayPrice: price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(
|
||||||
|
productID: String,
|
||||||
|
accountID: UUID
|
||||||
|
) async throws -> AccountStorePurchaseOutcome {
|
||||||
|
guard productID == "500tks", accountID == self.accountID else {
|
||||||
|
return .success(.unverified)
|
||||||
|
}
|
||||||
|
let transaction = AccountStoreTransaction(
|
||||||
|
id: 2_000_000_000_200,
|
||||||
|
productID: productID,
|
||||||
|
appAccountToken: accountID,
|
||||||
|
purchaseDate: Date(),
|
||||||
|
signedTransaction: String(repeating: "p", count: 100),
|
||||||
|
finishOperation: {}
|
||||||
|
)
|
||||||
|
let verification = AccountStoreVerification.verified(transaction)
|
||||||
|
transactions.append(verification)
|
||||||
|
return .success(verification)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
let values = transactions
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
values.forEach { continuation.yield($0) }
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,962 @@
|
|||||||
|
// AccountCenterView.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Optional account center. Signing in unlocks managed credits and referrals;
|
||||||
|
// local transcription and user-owned provider keys remain independent.
|
||||||
|
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct AccountCenterView: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||||
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
|
||||||
|
@State private var showSignOutConfirmation = false
|
||||||
|
@State private var showDeleteConfirmation = false
|
||||||
|
@State private var showDeleteReauthentication = false
|
||||||
|
@State private var showProfileEditor = false
|
||||||
|
@State private var displayNameDraft = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
palette.background.ignoresSafeArea()
|
||||||
|
sessionContent
|
||||||
|
}
|
||||||
|
.navigationTitle("account.title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.hidesTabBarWhenPushed()
|
||||||
|
.task(id: coordinator.accountID) {
|
||||||
|
guard let accountID = coordinator.accountID else {
|
||||||
|
coordinator.creditPurchases.reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await coordinator.refreshAccountData()
|
||||||
|
await coordinator.creditPurchases.prepare(accountID: accountID)
|
||||||
|
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||||
|
displayNameDraft = session.displayName ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
coordinator.creditPurchases.dismissSuccessMessage()
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"account.error.title",
|
||||||
|
isPresented: operationErrorBinding
|
||||||
|
) {
|
||||||
|
Button("common.done") {
|
||||||
|
coordinator.dismissOperationError()
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let key = coordinator.operationErrorKey {
|
||||||
|
Text(LocalizedStringKey(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert(
|
||||||
|
"account.profile.editTitle",
|
||||||
|
isPresented: $showProfileEditor
|
||||||
|
) {
|
||||||
|
TextField("account.profile.nickname", text: $displayNameDraft)
|
||||||
|
.textInputAutocapitalization(.words)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
|
Button("account.profile.save") {
|
||||||
|
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||||
|
saveDisplayName(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(!canSaveCurrentDisplayName)
|
||||||
|
} message: {
|
||||||
|
Text("account.profile.editMessage")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showDeleteReauthentication) {
|
||||||
|
deleteReauthenticationSheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sessionContent: some View {
|
||||||
|
switch coordinator.sessionPhase {
|
||||||
|
case .restoring:
|
||||||
|
AccountStateCard(
|
||||||
|
systemImage: "person.crop.circle",
|
||||||
|
titleKey: "account.loading.session",
|
||||||
|
showsProgress: true
|
||||||
|
)
|
||||||
|
case .signedOut:
|
||||||
|
signedOutContent
|
||||||
|
case let .signedIn(session):
|
||||||
|
signedInContent(session: session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var signedOutContent: some View {
|
||||||
|
ScrollView {
|
||||||
|
CardPageContent {
|
||||||
|
VStack(spacing: Spacing.lg) {
|
||||||
|
Image(systemName: "person.crop.circle.badge.plus")
|
||||||
|
.font(.system(.largeTitle, design: .rounded, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
VStack(spacing: Spacing.xs) {
|
||||||
|
Text("account.signedOut.title")
|
||||||
|
.font(.title2.bold())
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Text("account.signedOut.body")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||||
|
.disabled(coordinator.operation != nil)
|
||||||
|
|
||||||
|
if coordinator.operation == .signingIn {
|
||||||
|
ProgressView("account.signIn.loading")
|
||||||
|
.tint(palette.accent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if coordinator.pendingReferralCode != nil {
|
||||||
|
Label("account.referral.pendingAfterSignIn", systemImage: "link")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("account.signedOut.localUnaffected")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
.padding(Spacing.lg)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func signedInContent(session: AccountSession) -> some View {
|
||||||
|
ScrollView {
|
||||||
|
CardPageContent {
|
||||||
|
accountSummarySection(session: session)
|
||||||
|
accountRewardsContent(session: session)
|
||||||
|
securitySection
|
||||||
|
}
|
||||||
|
.padding(.bottom, Spacing.lg)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await coordinator.refreshAccountData(force: true)
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("account.center.signedIn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func accountRewardsContent(session: AccountSession) -> some View {
|
||||||
|
switch coordinator.snapshotPhase {
|
||||||
|
case .idle, .loading:
|
||||||
|
AccountStateCard(
|
||||||
|
systemImage: "arrow.triangle.2.circlepath",
|
||||||
|
titleKey: "account.loading.center",
|
||||||
|
showsProgress: true
|
||||||
|
)
|
||||||
|
case let .failed(messageKey):
|
||||||
|
AccountStateCard(
|
||||||
|
systemImage: "exclamationmark.triangle",
|
||||||
|
titleKey: messageKey,
|
||||||
|
actionKey: "account.retry"
|
||||||
|
) {
|
||||||
|
Task { await coordinator.refreshAccountData(force: true) }
|
||||||
|
}
|
||||||
|
case .loaded:
|
||||||
|
CardSection("account.storekit.section") {
|
||||||
|
VStack(spacing: Spacing.sm) {
|
||||||
|
AccountCreditPurchaseSection(
|
||||||
|
manager: coordinator.creditPurchases,
|
||||||
|
accountID: session.accountID,
|
||||||
|
onGranted: {
|
||||||
|
Task { await coordinator.refreshAccountData(force: true) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
purchaseHistoryLink(accountID: session.accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseHistoryLink(accountID: UUID) -> some View {
|
||||||
|
NavigationLink {
|
||||||
|
AccountPurchaseHistoryView(
|
||||||
|
manager: coordinator.creditPurchases,
|
||||||
|
accountID: accountID
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Spacing.md) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.frame(width: 24)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text("account.purchaseHistory.title")
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
.settingsListRow()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.surfaceCard()
|
||||||
|
.accessibilityIdentifier("account.purchaseHistory.link")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accountSummarySection(session: AccountSession) -> some View {
|
||||||
|
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||||
|
|
||||||
|
return HStack(spacing: Spacing.md) {
|
||||||
|
AccountAvatarView(
|
||||||
|
accountID: session.accountID,
|
||||||
|
displayName: session.displayName,
|
||||||
|
size: 52
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text(
|
||||||
|
session.displayName
|
||||||
|
?? AppL10n.string(
|
||||||
|
"account.profile.fallbackName",
|
||||||
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(TypeStyle.headline)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text(verbatim: "ID · \(session.accountID.uuidString.suffix(8))")
|
||||||
|
.font(TypeStyle.caption2.monospaced())
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
displayNameDraft = session.displayName ?? ""
|
||||||
|
showProfileEditor = true
|
||||||
|
} label: {
|
||||||
|
Group {
|
||||||
|
if coordinator.operation == .updatingProfile {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.and.pencil")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
.background(palette.surfaceElevated, in: Circle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(coordinator.operation != nil)
|
||||||
|
.accessibilityLabel("account.profile.editTitle")
|
||||||
|
}
|
||||||
|
.padding(Spacing.lg)
|
||||||
|
.background(palette.surface, in: shape)
|
||||||
|
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||||
|
.accessibilityIdentifier("account.summary")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSaveCurrentDisplayName: Bool {
|
||||||
|
guard case let .signedIn(session) = coordinator.sessionPhase else { return false }
|
||||||
|
return canSaveDisplayName(session: session)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func canSaveDisplayName(session: AccountSession) -> Bool {
|
||||||
|
let value = displayNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return !value.isEmpty
|
||||||
|
&& value.count <= 64
|
||||||
|
&& value != session.displayName
|
||||||
|
&& coordinator.operation == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveDisplayName(session: AccountSession) {
|
||||||
|
guard canSaveDisplayName(session: session) else { return }
|
||||||
|
let value = displayNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
Task {
|
||||||
|
if await coordinator.updateDisplayName(value) {
|
||||||
|
displayNameDraft = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var securitySection: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Button {
|
||||||
|
showSignOutConfirmation = true
|
||||||
|
} label: {
|
||||||
|
AccountActionRow(
|
||||||
|
titleKey: "account.signOut.action",
|
||||||
|
systemImage: "rectangle.portrait.and.arrow.right",
|
||||||
|
color: palette.textPrimary,
|
||||||
|
showsProgress: coordinator.operation == .signingOut
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(coordinator.operation != nil)
|
||||||
|
.confirmationDialog(
|
||||||
|
"account.signOut.confirmTitle",
|
||||||
|
isPresented: $showSignOutConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("account.signOut.action", role: .destructive) {
|
||||||
|
Task {
|
||||||
|
await coordinator.signOut()
|
||||||
|
if !coordinator.isSignedIn {
|
||||||
|
config.credentialSource = .byok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("account.signOut.confirmMessage")
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
|
Button(role: .destructive) {
|
||||||
|
showDeleteConfirmation = true
|
||||||
|
} label: {
|
||||||
|
AccountActionRow(
|
||||||
|
titleKey: "account.delete.action",
|
||||||
|
systemImage: "trash",
|
||||||
|
color: palette.danger,
|
||||||
|
showsProgress: coordinator.operation == .deletingAccount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(coordinator.operation != nil)
|
||||||
|
.confirmationDialog(
|
||||||
|
"account.delete.confirmTitle",
|
||||||
|
isPresented: $showDeleteConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("account.delete.continue", role: .destructive) {
|
||||||
|
showDeleteReauthentication = true
|
||||||
|
}
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
|
} message: {
|
||||||
|
Text("account.delete.confirmMessage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteReauthenticationSheet: some View {
|
||||||
|
NavigationStack {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||||
|
Image(systemName: "person.crop.circle.badge.xmark")
|
||||||
|
.font(.system(.largeTitle, design: .rounded, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.danger)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
|
Text("account.delete.reauthenticateBody")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
|
||||||
|
AccountAppleAuthorizationButton(
|
||||||
|
purpose: .deleteAccount,
|
||||||
|
onDeleted: {
|
||||||
|
showDeleteReauthentication = false
|
||||||
|
config.credentialSource = .byok
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.disabled(coordinator.operation != nil)
|
||||||
|
|
||||||
|
if coordinator.operation == .deletingAccount {
|
||||||
|
ProgressView("account.delete.loading")
|
||||||
|
.tint(palette.accent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(Spacing.lg)
|
||||||
|
.background(palette.background.ignoresSafeArea())
|
||||||
|
.navigationTitle("account.delete.reauthenticateTitle")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("common.cancel") {
|
||||||
|
showDeleteReauthentication = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
}
|
||||||
|
|
||||||
|
private var operationErrorBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { coordinator.operationErrorKey != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
coordinator.dismissOperationError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountAvatarView: View {
|
||||||
|
let accountID: UUID
|
||||||
|
let displayName: String?
|
||||||
|
let size: CGFloat
|
||||||
|
|
||||||
|
private static let gradients: [[Color]] = [
|
||||||
|
[.indigo, .blue],
|
||||||
|
[.purple, .pink],
|
||||||
|
[.teal, .cyan],
|
||||||
|
[.orange, .red],
|
||||||
|
[.mint, .green]
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Text(initials)
|
||||||
|
.font(.system(size: size * 0.34, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: Self.gradients[gradientIndex],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
),
|
||||||
|
in: Circle()
|
||||||
|
)
|
||||||
|
.overlay(Circle().stroke(.white.opacity(0.2), lineWidth: 0.5))
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var initials: String {
|
||||||
|
let parts = (displayName ?? "")
|
||||||
|
.split(whereSeparator: \.isWhitespace)
|
||||||
|
.prefix(2)
|
||||||
|
let value = parts.compactMap(\.first).map(String.init).joined().uppercased()
|
||||||
|
return value.isEmpty ? "O" : value
|
||||||
|
}
|
||||||
|
|
||||||
|
private var gradientIndex: Int {
|
||||||
|
let hash = accountID.uuidString.unicodeScalars.reduce(UInt32(2_166_136_261)) {
|
||||||
|
($0 ^ $1.value) &* 16_777_619
|
||||||
|
}
|
||||||
|
return Int(hash % UInt32(Self.gradients.count))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountCreditsSection: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
let snapshot: AccountCenterSnapshot
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Spacing.md) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text(
|
||||||
|
snapshot.credits.balance,
|
||||||
|
format: .number.grouping(.automatic)
|
||||||
|
)
|
||||||
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
|
.monospacedDigit()
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("account.credits.balance")
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Spacing.md)
|
||||||
|
|
||||||
|
HStack(spacing: Spacing.xxs) {
|
||||||
|
Text("account.credits.used")
|
||||||
|
Text(
|
||||||
|
snapshot.credits.usedCredits,
|
||||||
|
format: .number.grouping(.automatic)
|
||||||
|
)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountCreditProgress(
|
||||||
|
remaining: snapshot.credits.balance,
|
||||||
|
used: snapshot.credits.usedCredits,
|
||||||
|
showsLabels: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(Spacing.md)
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountCreditPurchaseSection: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||||
|
|
||||||
|
let accountID: UUID
|
||||||
|
let onGranted: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
|
if !manager.options.isEmpty {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(manager.options) { option in
|
||||||
|
purchaseOption(option)
|
||||||
|
if option.id != manager.options.last?.id {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
} else if manager.state == .loading {
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(palette.accent)
|
||||||
|
Text("account.storekit.loading")
|
||||||
|
.font(TypeStyle.footnote)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
.settingsListRow()
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
stateMessage
|
||||||
|
}
|
||||||
|
.onChange(of: manager.lastGrantedBalance) { previous, current in
|
||||||
|
guard current != nil, current != previous else { return }
|
||||||
|
onGranted()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
_ = await manager.purchase(
|
||||||
|
productID: option.productID,
|
||||||
|
accountID: accountID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Spacing.md) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||||
|
Text(option.credits, format: .number.grouping(.automatic))
|
||||||
|
.font(TypeStyle.title3.monospacedDigit())
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("account.storekit.credits")
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
|
||||||
|
Text(option.displayPrice)
|
||||||
|
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
|
||||||
|
Group {
|
||||||
|
if purchasingProductID == option.productID {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("account.storekit.purchase")
|
||||||
|
.font(TypeStyle.caption.weight(.semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, Spacing.sm)
|
||||||
|
.frame(minWidth: 54, minHeight: 34)
|
||||||
|
.background(
|
||||||
|
palette.accent,
|
||||||
|
in: RoundedRectangle(
|
||||||
|
cornerRadius: Radius.medium,
|
||||||
|
style: .continuous
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(Spacing.md)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 64)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(purchasingProductID != nil)
|
||||||
|
.accessibilityIdentifier("account.purchase.\(option.productID)")
|
||||||
|
.accessibilityLabel(
|
||||||
|
Text(
|
||||||
|
"\(Text(option.credits, format: .number)) \(Text("account.storekit.credits")) \(Text(option.displayPrice))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var purchasingProductID: String? {
|
||||||
|
guard case .purchasing(let productID) = manager.state else { return nil }
|
||||||
|
return productID
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var stateMessage: some View {
|
||||||
|
Group {
|
||||||
|
switch manager.state {
|
||||||
|
case .pending:
|
||||||
|
Label("account.storekit.pending", systemImage: "clock")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
case .succeeded(let credits):
|
||||||
|
Label {
|
||||||
|
Text(
|
||||||
|
String(
|
||||||
|
format: NSLocalizedString(
|
||||||
|
"account.storekit.succeeded",
|
||||||
|
comment: ""
|
||||||
|
),
|
||||||
|
credits
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
}
|
||||||
|
.font(TypeStyle.footnote)
|
||||||
|
.foregroundStyle(palette.success)
|
||||||
|
case .failed(let messageKey):
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
Label(
|
||||||
|
LocalizedStringKey(messageKey),
|
||||||
|
systemImage: "exclamationmark.triangle.fill"
|
||||||
|
)
|
||||||
|
.font(TypeStyle.footnote)
|
||||||
|
.foregroundStyle(palette.danger)
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
Button("account.retry") {
|
||||||
|
Task { await manager.prepare(accountID: accountID) }
|
||||||
|
}
|
||||||
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("account.purchase.status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditProgress: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
|
||||||
|
let remaining: Int64
|
||||||
|
let used: Int64
|
||||||
|
let showsLabels: Bool
|
||||||
|
|
||||||
|
init(remaining: Int64, used: Int64, showsLabels: Bool = true) {
|
||||||
|
self.remaining = remaining
|
||||||
|
self.used = used
|
||||||
|
self.showsLabels = showsLabels
|
||||||
|
}
|
||||||
|
|
||||||
|
private var usedFraction: Double {
|
||||||
|
fraction(for: used)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var remainingFraction: Double {
|
||||||
|
fraction(for: remaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fraction(for value: Int64) -> Double {
|
||||||
|
let available = Double(max(remaining, 0))
|
||||||
|
let consumed = Double(max(used, 0))
|
||||||
|
let total = available + consumed
|
||||||
|
return total > 0 ? Double(max(value, 0)) / total : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Spacing.xs) {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Capsule()
|
||||||
|
.fill(palette.surfaceElevated)
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(palette.success)
|
||||||
|
.frame(width: proxy.size.width * remainingFraction)
|
||||||
|
Rectangle()
|
||||||
|
.fill(palette.textTertiary.opacity(0.55))
|
||||||
|
.frame(width: proxy.size.width * usedFraction)
|
||||||
|
}
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 8)
|
||||||
|
|
||||||
|
if showsLabels {
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
percentageLabel(
|
||||||
|
"account.credits.remaining",
|
||||||
|
fraction: remainingFraction,
|
||||||
|
color: palette.success
|
||||||
|
)
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
percentageLabel(
|
||||||
|
"account.credits.used",
|
||||||
|
fraction: usedFraction,
|
||||||
|
color: palette.textTertiary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentageLabel(
|
||||||
|
_ titleKey: LocalizedStringKey,
|
||||||
|
fraction: Double,
|
||||||
|
color: Color
|
||||||
|
) -> some View {
|
||||||
|
let percentageText = fraction.formatted(
|
||||||
|
.percent.precision(.fractionLength(0))
|
||||||
|
)
|
||||||
|
return HStack(spacing: Spacing.xxs) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 7, height: 7)
|
||||||
|
Text(titleKey)
|
||||||
|
Text(verbatim: percentageText)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountInvitationButton: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
@State private var showsShareDrawer = false
|
||||||
|
|
||||||
|
let invitationURL: URL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
showsShareDrawer = true
|
||||||
|
} label: {
|
||||||
|
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
|
||||||
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, Spacing.sm)
|
||||||
|
.frame(minHeight: 38)
|
||||||
|
.background(
|
||||||
|
palette.success,
|
||||||
|
in: RoundedRectangle(
|
||||||
|
cornerRadius: Radius.medium,
|
||||||
|
style: .continuous
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.sheet(isPresented: $showsShareDrawer) {
|
||||||
|
AccountInvitationActivityView(invitationURL: invitationURL)
|
||||||
|
.presentationDetents([.medium, .large])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountInvitationActivityView: UIViewControllerRepresentable {
|
||||||
|
let invitationURL: URL
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||||
|
UIActivityViewController(
|
||||||
|
activityItems: [invitationURL],
|
||||||
|
applicationActivities: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(
|
||||||
|
_ uiViewController: UIActivityViewController,
|
||||||
|
context: Context
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
@MainActor
|
||||||
|
struct IAPReviewScreenshotHarness: View {
|
||||||
|
@StateObject private var purchaseManager: AccountCreditPurchaseManager
|
||||||
|
|
||||||
|
private let accountID = UUID(uuidString: "00000000-0000-0000-0000-000000000100")!
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_purchaseManager = StateObject(
|
||||||
|
wrappedValue: AccountCreditPurchaseManager(
|
||||||
|
service: IAPReviewAccountService(),
|
||||||
|
store: IAPReviewCreditStore()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
CardPageContent {
|
||||||
|
CardSection("account.credits.section") {
|
||||||
|
VStack(spacing: Spacing.sm) {
|
||||||
|
AccountCreditsSection(snapshot: Self.snapshot)
|
||||||
|
AccountCreditPurchaseSection(
|
||||||
|
manager: purchaseManager,
|
||||||
|
accountID: accountID,
|
||||||
|
onGranted: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.bottom, Spacing.lg)
|
||||||
|
}
|
||||||
|
.navigationTitle("account.rewards.title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await purchaseManager.prepare(accountID: accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let snapshot = AccountCenterSnapshot(
|
||||||
|
account: AccountSession(
|
||||||
|
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000100")!,
|
||||||
|
createdAtEpochSeconds: 0,
|
||||||
|
displayName: "OSG User"
|
||||||
|
),
|
||||||
|
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
|
||||||
|
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||||
|
referrals: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct IAPReviewAccountService: AccountCenterServicing {
|
||||||
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func createReferralCode() async throws -> String {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func redeemReferral(code: String) async throws {}
|
||||||
|
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||||
|
[
|
||||||
|
AccountCreditProduct(productID: "500tks", credits: 500),
|
||||||
|
AccountCreditProduct(productID: "1500tks", credits: 1_500),
|
||||||
|
AccountCreditProduct(productID: "3000tks", credits: 3_000)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class IAPReviewCreditStore: AccountCreditStore {
|
||||||
|
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||||
|
let price: String
|
||||||
|
switch productID {
|
||||||
|
case "500tks":
|
||||||
|
price = "¥8.00"
|
||||||
|
case "1500tks":
|
||||||
|
price = "¥18.00"
|
||||||
|
case "3000tks":
|
||||||
|
price = "¥28.00"
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return AccountStoreProduct(id: productID, displayPrice: price)
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(
|
||||||
|
productID: String,
|
||||||
|
accountID: UUID
|
||||||
|
) async throws -> AccountStorePurchaseOutcome {
|
||||||
|
.userCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private struct AccountActionRow: View {
|
||||||
|
let titleKey: LocalizedStringKey
|
||||||
|
let systemImage: String
|
||||||
|
let color: Color
|
||||||
|
let showsProgress: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
Label(titleKey, systemImage: systemImage)
|
||||||
|
.font(.body)
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
if showsProgress {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.settingsListRow()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AccountStateCard: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
|
||||||
|
let systemImage: String
|
||||||
|
let titleKey: String
|
||||||
|
var actionKey: LocalizedStringKey?
|
||||||
|
var showsProgress = false
|
||||||
|
var action: () -> Void = {}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: Spacing.md) {
|
||||||
|
if showsProgress {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.large)
|
||||||
|
.tint(palette.accent)
|
||||||
|
} else {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(LocalizedStringKey(titleKey))
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let actionKey {
|
||||||
|
Button(actionKey, action: action)
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Spacing.lg)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// AccountCreditPurchaseManager.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// StoreKit credit purchases are isolated from the voluntary tip product.
|
||||||
|
// Transactions finish only after the account server verifies and records them.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardHostSupport
|
||||||
|
|
||||||
|
struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
||||||
|
var id: String { productID }
|
||||||
|
|
||||||
|
let productID: String
|
||||||
|
let credits: Int64
|
||||||
|
let displayPrice: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AccountCreditPurchaseManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case purchasing(productID: String)
|
||||||
|
case pending
|
||||||
|
case succeeded(credits: Int64)
|
||||||
|
case failed(messageKey: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum HistoryPhase: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case loaded([AccountCreditPurchaseRecord])
|
||||||
|
case failed(messageKey: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var state: State = .idle
|
||||||
|
@Published private(set) var options: [AccountCreditPurchaseOption] = []
|
||||||
|
@Published private(set) var lastGrantedBalance: Int64?
|
||||||
|
@Published private(set) var historyPhase: HistoryPhase = .idle
|
||||||
|
@Published private(set) var isLoadingMoreHistory = false
|
||||||
|
@Published private(set) var historyLoadMoreErrorKey: String?
|
||||||
|
|
||||||
|
private let service: any AccountCenterServicing
|
||||||
|
private let store: any AccountCreditStore
|
||||||
|
private var updatesTask: Task<Void, Never>?
|
||||||
|
private var activeAccountID: UUID?
|
||||||
|
private var configuredProductIDs: Set<String> = []
|
||||||
|
private var nextHistoryCursor: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
service: any AccountCenterServicing,
|
||||||
|
store: any AccountCreditStore = LiveAccountCreditStore()
|
||||||
|
) {
|
||||||
|
self.service = service
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(accountID: UUID) async {
|
||||||
|
guard activeAccountID != accountID || options.isEmpty else { return }
|
||||||
|
reset(for: accountID)
|
||||||
|
state = .loading
|
||||||
|
do {
|
||||||
|
let catalog = try await service.loadCreditProducts()
|
||||||
|
configuredProductIDs = Set(catalog.map(\.productID))
|
||||||
|
var loadedOptions: [AccountCreditPurchaseOption] = []
|
||||||
|
for creditProduct in catalog {
|
||||||
|
guard let product = try await store.product(for: creditProduct.productID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loadedOptions.append(
|
||||||
|
AccountCreditPurchaseOption(
|
||||||
|
productID: creditProduct.productID,
|
||||||
|
credits: creditProduct.credits,
|
||||||
|
displayPrice: product.displayPrice
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard !loadedOptions.isEmpty else {
|
||||||
|
throw AccountCreditPurchaseError.productUnavailable
|
||||||
|
}
|
||||||
|
options = loadedOptions.sorted { $0.credits < $1.credits }
|
||||||
|
state = .idle
|
||||||
|
listenForUpdates(accountID: accountID)
|
||||||
|
_ = await reconcileUnfinished(accountID: accountID)
|
||||||
|
} catch {
|
||||||
|
state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func purchase(productID: String, accountID: UUID) async -> Bool {
|
||||||
|
guard activeAccountID == accountID,
|
||||||
|
options.contains(where: { $0.productID == productID }) else {
|
||||||
|
state = .failed(messageKey: "account.storekit.error.productUnavailable")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state = .purchasing(productID: productID)
|
||||||
|
do {
|
||||||
|
switch try await store.purchase(productID: productID, accountID: accountID) {
|
||||||
|
case .success(let verification):
|
||||||
|
try await process(verification, accountID: accountID)
|
||||||
|
return true
|
||||||
|
case .pending:
|
||||||
|
state = .pending
|
||||||
|
case .userCancelled:
|
||||||
|
state = .idle
|
||||||
|
@unknown default:
|
||||||
|
state = .failed(messageKey: "account.storekit.error.unknown")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTransientState() {
|
||||||
|
switch state {
|
||||||
|
case .succeeded, .failed, .pending:
|
||||||
|
state = .idle
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissSuccessMessage() {
|
||||||
|
guard case .succeeded = state else { return }
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadPurchaseHistory(accountID: UUID, force: Bool = false) async {
|
||||||
|
guard activeAccountID == accountID else {
|
||||||
|
historyPhase = .loaded([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !force, case .loaded = historyPhase {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
historyPhase = .loading
|
||||||
|
historyLoadMoreErrorKey = nil
|
||||||
|
do {
|
||||||
|
let page = try await service.loadCreditPurchaseHistory(
|
||||||
|
limit: 50,
|
||||||
|
cursor: nil
|
||||||
|
)
|
||||||
|
guard activeAccountID == accountID else { return }
|
||||||
|
nextHistoryCursor = page.nextCursor
|
||||||
|
historyPhase = .loaded(page.items)
|
||||||
|
} catch {
|
||||||
|
guard activeAccountID == accountID else { return }
|
||||||
|
nextHistoryCursor = nil
|
||||||
|
historyPhase = .failed(messageKey: "account.purchaseHistory.error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadNextPurchaseHistoryPage(accountID: UUID) async {
|
||||||
|
guard activeAccountID == accountID,
|
||||||
|
!isLoadingMoreHistory,
|
||||||
|
let cursor = nextHistoryCursor,
|
||||||
|
case .loaded(let currentItems) = historyPhase else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMoreHistory = true
|
||||||
|
historyLoadMoreErrorKey = nil
|
||||||
|
defer { isLoadingMoreHistory = false }
|
||||||
|
do {
|
||||||
|
let page = try await service.loadCreditPurchaseHistory(
|
||||||
|
limit: 50,
|
||||||
|
cursor: cursor
|
||||||
|
)
|
||||||
|
guard activeAccountID == accountID,
|
||||||
|
case .loaded = historyPhase else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let existingIDs = Set(currentItems.map(\.transactionID))
|
||||||
|
let newItems = page.items.filter { !existingIDs.contains($0.transactionID) }
|
||||||
|
nextHistoryCursor = page.nextCursor
|
||||||
|
historyPhase = .loaded(currentItems + newItems)
|
||||||
|
} catch {
|
||||||
|
guard activeAccountID == accountID else { return }
|
||||||
|
historyLoadMoreErrorKey = "account.purchaseHistory.loadMoreError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
updatesTask = nil
|
||||||
|
activeAccountID = nil
|
||||||
|
options = []
|
||||||
|
configuredProductIDs = []
|
||||||
|
lastGrantedBalance = nil
|
||||||
|
historyPhase = .idle
|
||||||
|
isLoadingMoreHistory = false
|
||||||
|
historyLoadMoreErrorKey = nil
|
||||||
|
nextHistoryCursor = nil
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func reconcileUnfinished(accountID: UUID) async -> Bool {
|
||||||
|
var reconciled = false
|
||||||
|
for await verification in store.unfinishedTransactions() {
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
do {
|
||||||
|
try await process(verification, accountID: accountID)
|
||||||
|
reconciled = true
|
||||||
|
} catch {
|
||||||
|
state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reconciled
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listenForUpdates(accountID: UUID) {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
let updates = store.transactionUpdates()
|
||||||
|
updatesTask = Task { [weak self] in
|
||||||
|
for await verification in updates {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard let self else { return }
|
||||||
|
do {
|
||||||
|
try await self.process(verification, accountID: accountID)
|
||||||
|
} catch {
|
||||||
|
self.state = .failed(messageKey: Self.messageKey(for: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func process(
|
||||||
|
_ verification: AccountStoreVerification,
|
||||||
|
accountID: UUID
|
||||||
|
) async throws {
|
||||||
|
guard case .verified(let transaction) = verification,
|
||||||
|
transaction.appAccountToken == accountID,
|
||||||
|
configuredProductIDs.contains(transaction.productID) else {
|
||||||
|
throw AccountCreditPurchaseError.verificationFailed
|
||||||
|
}
|
||||||
|
let result = try await service.submitCreditTransaction(transaction.signedTransaction)
|
||||||
|
guard result.transactionID == String(transaction.id),
|
||||||
|
result.productID == transaction.productID else {
|
||||||
|
throw AccountCreditPurchaseError.invalidServerResponse
|
||||||
|
}
|
||||||
|
await transaction.finish()
|
||||||
|
lastGrantedBalance = result.balanceAfter
|
||||||
|
historyPhase = .idle
|
||||||
|
nextHistoryCursor = nil
|
||||||
|
state = .succeeded(credits: result.creditsGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reset(for accountID: UUID) {
|
||||||
|
updatesTask?.cancel()
|
||||||
|
updatesTask = nil
|
||||||
|
activeAccountID = accountID
|
||||||
|
options = []
|
||||||
|
configuredProductIDs = []
|
||||||
|
lastGrantedBalance = nil
|
||||||
|
historyPhase = .idle
|
||||||
|
isLoadingMoreHistory = false
|
||||||
|
historyLoadMoreErrorKey = nil
|
||||||
|
nextHistoryCursor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func messageKey(for error: Error) -> String {
|
||||||
|
if let purchaseError = error as? AccountCreditPurchaseError {
|
||||||
|
switch purchaseError {
|
||||||
|
case .productUnavailable:
|
||||||
|
return "account.storekit.error.productUnavailable"
|
||||||
|
case .verificationFailed:
|
||||||
|
return "account.storekit.error.verification"
|
||||||
|
case .invalidServerResponse:
|
||||||
|
return "account.storekit.error.server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let accountError = error as? AccountAPIError {
|
||||||
|
switch accountError {
|
||||||
|
case .unauthorized, .sessionUnavailable, .refreshTokenReuse:
|
||||||
|
return "account.storekit.error.session"
|
||||||
|
case .externalServiceUnavailable, .transport:
|
||||||
|
return "account.storekit.error.network"
|
||||||
|
case .conflict:
|
||||||
|
return "account.storekit.error.conflict"
|
||||||
|
default:
|
||||||
|
return "account.storekit.error.server"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "account.storekit.error.unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccountCreditPurchaseError: Error {
|
||||||
|
case productUnavailable
|
||||||
|
case verificationFailed
|
||||||
|
case invalidServerResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
// AccountCreditStore.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Isolates StoreKit so purchase orchestration remains hermetic and testable.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import StoreKit
|
||||||
|
|
||||||
|
struct AccountStoreProduct: Equatable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let displayPrice: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountStoreTransaction: Sendable {
|
||||||
|
let id: UInt64
|
||||||
|
let productID: String
|
||||||
|
let appAccountToken: UUID?
|
||||||
|
let purchaseDate: Date
|
||||||
|
let signedTransaction: String
|
||||||
|
|
||||||
|
private let finishOperation: @Sendable () async -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UInt64,
|
||||||
|
productID: String,
|
||||||
|
appAccountToken: UUID?,
|
||||||
|
purchaseDate: Date = .distantPast,
|
||||||
|
signedTransaction: String,
|
||||||
|
finishOperation: @escaping @Sendable () async -> Void
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.productID = productID
|
||||||
|
self.appAccountToken = appAccountToken
|
||||||
|
self.purchaseDate = purchaseDate
|
||||||
|
self.signedTransaction = signedTransaction
|
||||||
|
self.finishOperation = finishOperation
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() async {
|
||||||
|
await finishOperation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountStoreVerification: Sendable {
|
||||||
|
case verified(AccountStoreTransaction)
|
||||||
|
case unverified
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountStorePurchaseOutcome: Sendable {
|
||||||
|
case success(AccountStoreVerification)
|
||||||
|
case pending
|
||||||
|
case userCancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol AccountCreditStore {
|
||||||
|
func product(for productID: String) async throws -> AccountStoreProduct?
|
||||||
|
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
|
||||||
|
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
|
||||||
|
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
|
||||||
|
func allTransactions() -> AsyncStream<AccountStoreVerification>
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LiveAccountCreditStore: AccountCreditStore {
|
||||||
|
private var productsByID: [String: Product] = [:]
|
||||||
|
|
||||||
|
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||||
|
guard let product = try await loadProduct(for: productID) else { return nil }
|
||||||
|
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
|
||||||
|
guard let product = try await loadProduct(for: productID) else {
|
||||||
|
return .success(.unverified)
|
||||||
|
}
|
||||||
|
switch try await product.purchase(options: [.appAccountToken(accountID)]) {
|
||||||
|
case .success(let verification):
|
||||||
|
return .success(Self.map(verification))
|
||||||
|
case .pending:
|
||||||
|
return .pending
|
||||||
|
case .userCancelled:
|
||||||
|
return .userCancelled
|
||||||
|
@unknown default:
|
||||||
|
return .success(.unverified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
Self.stream(from: Transaction.unfinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
Self.stream(from: Transaction.updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||||
|
Self.stream(from: Transaction.all)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadProduct(for productID: String) async throws -> Product? {
|
||||||
|
if let product = productsByID[productID] {
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
let loaded = try await Product.products(for: [productID])
|
||||||
|
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
|
||||||
|
productsByID[productID] = product
|
||||||
|
return product
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
|
||||||
|
guard case .verified(let transaction) = verification else { return .unverified }
|
||||||
|
return .verified(
|
||||||
|
AccountStoreTransaction(
|
||||||
|
id: transaction.id,
|
||||||
|
productID: transaction.productID,
|
||||||
|
appAccountToken: transaction.appAccountToken,
|
||||||
|
purchaseDate: transaction.purchaseDate,
|
||||||
|
signedTransaction: verification.jwsRepresentation,
|
||||||
|
finishOperation: { await transaction.finish() }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func stream(
|
||||||
|
from transactions: Transaction.Transactions
|
||||||
|
) -> AsyncStream<AccountStoreVerification> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
for await verification in transactions {
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
continuation.yield(map(verification))
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// AccountModels.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Account-center domain models and narrow service boundaries. Transport DTOs
|
||||||
|
// stay in the eventual auth/network adapter so this UI is not coupled to it.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Notification.Name {
|
||||||
|
static let osgOpenAccountDeepLink = Notification.Name("osg.OpenAccountDeepLink")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountSession: Equatable, Sendable {
|
||||||
|
let accountID: UUID
|
||||||
|
let createdAtEpochSeconds: Int64
|
||||||
|
let displayName: String?
|
||||||
|
|
||||||
|
init(accountID: UUID, createdAtEpochSeconds: Int64, displayName: String? = nil) {
|
||||||
|
self.accountID = accountID
|
||||||
|
self.createdAtEpochSeconds = createdAtEpochSeconds
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditSummary: Equatable, Sendable {
|
||||||
|
let balance: Int64
|
||||||
|
let usedCredits: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditProduct: Identifiable, Equatable, Sendable {
|
||||||
|
var id: String { productID }
|
||||||
|
|
||||||
|
let productID: String
|
||||||
|
let credits: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditPurchase: Equatable, Sendable {
|
||||||
|
let transactionID: String
|
||||||
|
let productID: String
|
||||||
|
let creditsGranted: Int64
|
||||||
|
let balanceAfter: Int64
|
||||||
|
let replayed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountCreditPurchaseStatus: String, Equatable, Sendable {
|
||||||
|
case credited
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditPurchaseRecord: Identifiable, Equatable, Sendable {
|
||||||
|
var id: String { transactionID }
|
||||||
|
|
||||||
|
let transactionID: String
|
||||||
|
let productID: String
|
||||||
|
let creditsGranted: Int64
|
||||||
|
let balanceAfter: Int64
|
||||||
|
let purchasedAt: Date
|
||||||
|
let status: AccountCreditPurchaseStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCreditPurchaseHistoryPage: Equatable, Sendable {
|
||||||
|
let items: [AccountCreditPurchaseRecord]
|
||||||
|
let nextCursor: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountReferralStatus: String, CaseIterable, Equatable, Sendable {
|
||||||
|
case pending
|
||||||
|
case rewarded
|
||||||
|
case ineligible
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountReferral: Identifiable, Equatable, Sendable {
|
||||||
|
let id: UUID
|
||||||
|
let status: AccountReferralStatus
|
||||||
|
let createdAtEpochSeconds: Int64?
|
||||||
|
let rewardCredits: Int64?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountReferralProfile: Equatable, Sendable {
|
||||||
|
let code: String?
|
||||||
|
let boundCode: String?
|
||||||
|
let inviterRewardCredits: Int64?
|
||||||
|
let inviteeRewardCredits: Int64?
|
||||||
|
|
||||||
|
init(
|
||||||
|
code: String?,
|
||||||
|
boundCode: String?,
|
||||||
|
inviterRewardCredits: Int64? = nil,
|
||||||
|
inviteeRewardCredits: Int64? = nil
|
||||||
|
) {
|
||||||
|
self.code = code
|
||||||
|
self.boundCode = boundCode
|
||||||
|
self.inviterRewardCredits = inviterRewardCredits
|
||||||
|
self.inviteeRewardCredits = inviteeRewardCredits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountCenterSnapshot: Equatable, Sendable {
|
||||||
|
let account: AccountSession
|
||||||
|
let credits: AccountCreditSummary
|
||||||
|
let referralProfile: AccountReferralProfile
|
||||||
|
let referrals: [AccountReferral]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountReferralSummary: Equatable, Sendable {
|
||||||
|
let pending: Int
|
||||||
|
let rewarded: Int
|
||||||
|
let ineligible: Int
|
||||||
|
|
||||||
|
init(pending: Int, rewarded: Int, ineligible: Int) {
|
||||||
|
self.pending = pending
|
||||||
|
self.rewarded = rewarded
|
||||||
|
self.ineligible = ineligible
|
||||||
|
}
|
||||||
|
|
||||||
|
init(referrals: [AccountReferral]) {
|
||||||
|
pending = referrals.count { $0.status == .pending }
|
||||||
|
rewarded = referrals.count { $0.status == .rewarded }
|
||||||
|
ineligible = referrals.count { $0.status == .ineligible }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppleAuthorizationPayload: Equatable, Sendable {
|
||||||
|
let identityToken: String
|
||||||
|
let authorizationCode: String
|
||||||
|
let nonce: String
|
||||||
|
let displayName: String?
|
||||||
|
|
||||||
|
init(
|
||||||
|
identityToken: String,
|
||||||
|
authorizationCode: String,
|
||||||
|
nonce: String,
|
||||||
|
displayName: String? = nil
|
||||||
|
) {
|
||||||
|
self.identityToken = identityToken
|
||||||
|
self.authorizationCode = authorizationCode
|
||||||
|
self.nonce = nonce
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol AccountSessionServicing: Sendable {
|
||||||
|
func restoreSession() async throws -> AccountSession?
|
||||||
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession
|
||||||
|
func signOut() async throws
|
||||||
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws
|
||||||
|
func prepareManagedGateway() async throws
|
||||||
|
func clearManagedGateway() async
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AccountSessionServicing {
|
||||||
|
func prepareManagedGateway() async throws {}
|
||||||
|
func clearManagedGateway() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol AccountCenterServicing: Sendable {
|
||||||
|
func loadAccountCenter() async throws -> AccountCenterSnapshot
|
||||||
|
func loadAccountCenter(
|
||||||
|
cachedSnapshot: AccountCenterSnapshot?
|
||||||
|
) async throws -> AccountCenterSnapshot
|
||||||
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession
|
||||||
|
func createReferralCode() async throws -> String
|
||||||
|
func redeemReferral(code: String) async throws
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct]
|
||||||
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase
|
||||||
|
func loadCreditPurchaseHistory(
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> AccountCreditPurchaseHistoryPage
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AccountCenterServicing {
|
||||||
|
func loadAccountCenter(
|
||||||
|
cachedSnapshot: AccountCenterSnapshot?
|
||||||
|
) async throws -> AccountCenterSnapshot {
|
||||||
|
try await loadAccountCenter()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditPurchaseHistory(
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AccountDependencies: Sendable {
|
||||||
|
let sessionService: any AccountSessionServicing
|
||||||
|
let centerService: any AccountCenterServicing
|
||||||
|
|
||||||
|
static let unavailable: AccountDependencies = {
|
||||||
|
let service = UnavailableAccountService()
|
||||||
|
return AccountDependencies(sessionService: service, centerService: service)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AccountIntegrationError: Error, Equatable, Sendable {
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct UnavailableAccountService: AccountSessionServicing, AccountCenterServicing {
|
||||||
|
func restoreSession() async throws -> AccountSession? {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async throws {}
|
||||||
|
|
||||||
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareManagedGateway() async throws {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearManagedGateway() async {}
|
||||||
|
|
||||||
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func createReferralCode() async throws -> String {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func redeemReferral(code: String) async throws {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||||
|
throw AccountIntegrationError.unavailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ReferralUniversalLink {
|
||||||
|
static let host = "osglab.com"
|
||||||
|
static let codeLength = 22
|
||||||
|
|
||||||
|
static func code(from url: URL) -> String? {
|
||||||
|
guard url.scheme?.lowercased() == "https",
|
||||||
|
url.host?.lowercased() == host
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = url.path.split(separator: "/", omittingEmptySubsequences: true)
|
||||||
|
guard components.count == 2, components[0] == "i" else { return nil }
|
||||||
|
|
||||||
|
let code = String(components[1])
|
||||||
|
guard isValid(code: code) else { return nil }
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isValid(code: String) -> Bool {
|
||||||
|
guard code.utf8.count == codeLength else { return false }
|
||||||
|
return code.utf8.allSatisfy { byte in
|
||||||
|
(48...57).contains(byte)
|
||||||
|
|| (65...90).contains(byte)
|
||||||
|
|| (97...122).contains(byte)
|
||||||
|
|| byte == 45
|
||||||
|
|| byte == 95
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func invitationURL(for code: String) -> URL? {
|
||||||
|
guard isValid(code: code) else { return nil }
|
||||||
|
return URL(string: "https://\(host)/i/\(code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
protocol PendingReferralCodeStoring: AnyObject {
|
||||||
|
var code: String? { get }
|
||||||
|
func save(_ code: String)
|
||||||
|
func clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
|
||||||
|
private static let storageKey = "account.pendingReferralCode.v1"
|
||||||
|
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
var code: String? {
|
||||||
|
guard let value = defaults.string(forKey: Self.storageKey),
|
||||||
|
ReferralUniversalLink.isValid(code: value)
|
||||||
|
else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ code: String) {
|
||||||
|
guard ReferralUniversalLink.isValid(code: code) else { return }
|
||||||
|
defaults.set(code, forKey: Self.storageKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clear() {
|
||||||
|
defaults.removeObject(forKey: Self.storageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// AccountPurchaseHistoryView.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Displays verified StoreKit credit purchases for the active OSG account.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AccountPurchaseHistoryView: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
|
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||||
|
|
||||||
|
let accountID: UUID
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch manager.historyPhase {
|
||||||
|
case .idle, .loading:
|
||||||
|
ProgressView("account.purchaseHistory.loading")
|
||||||
|
.tint(palette.accent)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .loaded(let records):
|
||||||
|
if records.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"account.purchaseHistory.empty",
|
||||||
|
systemImage: "clock.arrow.circlepath"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
purchaseList(records)
|
||||||
|
}
|
||||||
|
case .failed(let messageKey):
|
||||||
|
VStack(spacing: Spacing.md) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(palette.warning)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
Text(LocalizedStringKey(messageKey))
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
Button("account.retry") {
|
||||||
|
Task {
|
||||||
|
await manager.loadPurchaseHistory(
|
||||||
|
accountID: accountID,
|
||||||
|
force: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(palette.background.ignoresSafeArea())
|
||||||
|
.navigationTitle("account.purchaseHistory.title")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.hidesTabBarWhenPushed()
|
||||||
|
.task {
|
||||||
|
await manager.loadPurchaseHistory(accountID: accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseList(
|
||||||
|
_ records: [AccountCreditPurchaseRecord]
|
||||||
|
) -> some View {
|
||||||
|
List {
|
||||||
|
ForEach(records) { record in
|
||||||
|
purchaseRow(record)
|
||||||
|
.listRowBackground(palette.surface)
|
||||||
|
.listRowSeparatorTint(palette.divider)
|
||||||
|
.task {
|
||||||
|
guard record.id == records.last?.id else { return }
|
||||||
|
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.isLoadingMoreHistory {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
.tint(palette.accent)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.listRowBackground(palette.surface)
|
||||||
|
} else if let errorKey = manager.historyLoadMoreErrorKey {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Text(LocalizedStringKey(errorKey))
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.listRowBackground(palette.surface)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(palette.background)
|
||||||
|
.accessibilityIdentifier("account.purchaseHistory.list")
|
||||||
|
.refreshable {
|
||||||
|
await manager.loadPurchaseHistory(accountID: accountID, force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseRow(
|
||||||
|
_ record: AccountCreditPurchaseRecord
|
||||||
|
) -> some View {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||||
|
Text("+")
|
||||||
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.foregroundStyle(palette.success)
|
||||||
|
Text(record.creditsGranted, format: .number.grouping(.automatic))
|
||||||
|
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("account.storekit.credits")
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
Text(purchaseDateText(record.purchasedAt))
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||||
|
Text(balanceAfterText(record.balanceAfter))
|
||||||
|
.font(TypeStyle.caption.monospacedDigit())
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
Text("account.purchaseHistory.status.credited")
|
||||||
|
.font(TypeStyle.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(palette.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, Spacing.xs)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func purchaseDateText(_ date: Date) -> String {
|
||||||
|
date.formatted(
|
||||||
|
.dateTime
|
||||||
|
.year()
|
||||||
|
.month()
|
||||||
|
.day()
|
||||||
|
.hour()
|
||||||
|
.minute()
|
||||||
|
.locale(config.uiLanguage.swiftUILocale)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func balanceAfterText(_ balance: Int64) -> String {
|
||||||
|
AppL10n.format(
|
||||||
|
"account.purchaseHistory.balanceAfter",
|
||||||
|
language: config.uiLanguage,
|
||||||
|
balance.formatted(.number.grouping(.automatic))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
// AccountSessionCoordinator.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Main-actor state machine for optional account features. Local and BYOK
|
||||||
|
// features never consult this coordinator and remain available when signed out.
|
||||||
|
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AccountSessionCoordinator: ObservableObject {
|
||||||
|
enum SessionPhase: Equatable {
|
||||||
|
case restoring
|
||||||
|
case signedOut
|
||||||
|
case signedIn(AccountSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SnapshotPhase: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case loaded(AccountCenterSnapshot)
|
||||||
|
case failed(messageKey: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Operation: Equatable {
|
||||||
|
case signingIn
|
||||||
|
case signingOut
|
||||||
|
case redeemingReferral
|
||||||
|
case preparingManagedGateway
|
||||||
|
case updatingProfile
|
||||||
|
case deletingAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var sessionPhase: SessionPhase = .restoring
|
||||||
|
@Published private(set) var snapshotPhase: SnapshotPhase = .idle
|
||||||
|
@Published private(set) var operation: Operation?
|
||||||
|
@Published private(set) var operationErrorKey: String?
|
||||||
|
@Published private(set) var pendingReferralCode: String?
|
||||||
|
@Published private(set) var shouldPresentAccountCenter = false
|
||||||
|
@Published private(set) var lastAccountRefreshAt: Date?
|
||||||
|
@Published private(set) var isRefreshingAccountData = false
|
||||||
|
@Published private(set) var accountRefreshErrorKey: String?
|
||||||
|
|
||||||
|
let creditPurchases: AccountCreditPurchaseManager
|
||||||
|
|
||||||
|
private let sessionService: any AccountSessionServicing
|
||||||
|
private let centerService: any AccountCenterServicing
|
||||||
|
private let pendingReferralStore: any PendingReferralCodeStoring
|
||||||
|
private let accountRefreshInterval: TimeInterval
|
||||||
|
private let now: () -> Date
|
||||||
|
private var didAttemptRestore = false
|
||||||
|
private var accountRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
dependencies: AccountDependencies,
|
||||||
|
creditStore: any AccountCreditStore = LiveAccountCreditStore(),
|
||||||
|
pendingReferralStore: any PendingReferralCodeStoring =
|
||||||
|
UserDefaultsPendingReferralCodeStore(),
|
||||||
|
accountRefreshInterval: TimeInterval = 10 * 60,
|
||||||
|
now: @escaping () -> Date = Date.init
|
||||||
|
) {
|
||||||
|
sessionService = dependencies.sessionService
|
||||||
|
centerService = dependencies.centerService
|
||||||
|
creditPurchases = AccountCreditPurchaseManager(
|
||||||
|
service: dependencies.centerService,
|
||||||
|
store: creditStore
|
||||||
|
)
|
||||||
|
self.pendingReferralStore = pendingReferralStore
|
||||||
|
self.accountRefreshInterval = accountRefreshInterval
|
||||||
|
self.now = now
|
||||||
|
pendingReferralCode = pendingReferralStore.code
|
||||||
|
}
|
||||||
|
|
||||||
|
var isSignedIn: Bool {
|
||||||
|
if case .signedIn = sessionPhase {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var accountID: UUID? {
|
||||||
|
guard case let .signedIn(session) = sessionPhase else { return nil }
|
||||||
|
return session.accountID
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreIfNeeded() async {
|
||||||
|
guard !didAttemptRestore else { return }
|
||||||
|
didAttemptRestore = true
|
||||||
|
sessionPhase = .restoring
|
||||||
|
operationErrorKey = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
guard let session = try await sessionService.restoreSession() else {
|
||||||
|
sessionPhase = .signedOut
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sessionPhase = .signedIn(session)
|
||||||
|
await redeemPendingReferralIfNeeded()
|
||||||
|
await refreshAccountData(force: true)
|
||||||
|
} catch {
|
||||||
|
sessionPhase = .signedOut
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.restore"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func handleIncomingURL(_ url: URL) -> Bool {
|
||||||
|
guard let code = ReferralUniversalLink.code(from: url) else { return false }
|
||||||
|
|
||||||
|
pendingReferralStore.save(code)
|
||||||
|
pendingReferralCode = code
|
||||||
|
shouldPresentAccountCenter = true
|
||||||
|
operationErrorKey = nil
|
||||||
|
|
||||||
|
if isSignedIn {
|
||||||
|
Task {
|
||||||
|
await redeemPendingReferralIfNeeded()
|
||||||
|
await refreshAccountData(force: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeAccountCenterPresentation() -> Bool {
|
||||||
|
guard shouldPresentAccountCenter else { return false }
|
||||||
|
shouldPresentAccountCenter = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(with payload: AppleAuthorizationPayload) async {
|
||||||
|
guard operation == nil else { return }
|
||||||
|
operation = .signingIn
|
||||||
|
operationErrorKey = nil
|
||||||
|
defer { operation = nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let session = try await sessionService.signIn(with: payload)
|
||||||
|
sessionPhase = .signedIn(session)
|
||||||
|
await redeemPendingReferralIfNeeded()
|
||||||
|
await refreshAccountData(force: true)
|
||||||
|
} catch {
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.signIn"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owns the single account snapshot consumed by Settings and Account Center.
|
||||||
|
/// Page entry uses the freshness window; explicit user actions force refresh.
|
||||||
|
func refreshAccountData(force: Bool = false) async {
|
||||||
|
guard let accountID else {
|
||||||
|
snapshotPhase = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let accountRefreshTask {
|
||||||
|
await accountRefreshTask.value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !force,
|
||||||
|
case .loaded = snapshotPhase,
|
||||||
|
let lastAccountRefreshAt,
|
||||||
|
now().timeIntervalSince(lastAccountRefreshAt) < accountRefreshInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Keep the current snapshot visible while refreshing in the background.
|
||||||
|
if case .loaded = snapshotPhase {
|
||||||
|
// No state transition is needed for an existing snapshot.
|
||||||
|
} else {
|
||||||
|
snapshotPhase = .loading
|
||||||
|
}
|
||||||
|
|
||||||
|
let task = Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
await self.performAccountRefresh(for: accountID)
|
||||||
|
}
|
||||||
|
accountRefreshTask = task
|
||||||
|
await task.value
|
||||||
|
accountRefreshTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisplayName(_ displayName: String) async -> Bool {
|
||||||
|
guard operation == nil, isSignedIn else { return false }
|
||||||
|
operation = .updatingProfile
|
||||||
|
operationErrorKey = nil
|
||||||
|
defer { operation = nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let account = try await centerService.updateDisplayName(displayName)
|
||||||
|
sessionPhase = .signedIn(account)
|
||||||
|
if case let .loaded(snapshot) = snapshotPhase {
|
||||||
|
snapshotPhase = .loaded(
|
||||||
|
AccountCenterSnapshot(
|
||||||
|
account: account,
|
||||||
|
credits: snapshot.credits,
|
||||||
|
referralProfile: snapshot.referralProfile,
|
||||||
|
referrals: snapshot.referrals
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.updateProfile"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareManagedGateway() async -> Bool {
|
||||||
|
guard operation == nil, isSignedIn else { return false }
|
||||||
|
operation = .preparingManagedGateway
|
||||||
|
operationErrorKey = nil
|
||||||
|
defer { operation = nil }
|
||||||
|
do {
|
||||||
|
try await sessionService.prepareManagedGateway()
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.managedGateway"
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearManagedGateway() async {
|
||||||
|
await sessionService.clearManagedGateway()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async {
|
||||||
|
guard operation == nil, isSignedIn else { return }
|
||||||
|
operation = .signingOut
|
||||||
|
operationErrorKey = nil
|
||||||
|
defer { operation = nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await sessionService.signOut()
|
||||||
|
creditPurchases.reset()
|
||||||
|
clearAccountRefreshState()
|
||||||
|
sessionPhase = .signedOut
|
||||||
|
snapshotPhase = .idle
|
||||||
|
} catch {
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.signOut"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(with payload: AppleAuthorizationPayload) async {
|
||||||
|
guard operation == nil, isSignedIn else { return }
|
||||||
|
operation = .deletingAccount
|
||||||
|
operationErrorKey = nil
|
||||||
|
defer { operation = nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await sessionService.deleteAccount(with: payload)
|
||||||
|
creditPurchases.reset()
|
||||||
|
clearAccountRefreshState()
|
||||||
|
sessionPhase = .signedOut
|
||||||
|
snapshotPhase = .idle
|
||||||
|
pendingReferralStore.clear()
|
||||||
|
pendingReferralCode = nil
|
||||||
|
} catch {
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.delete"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordAppleAuthorizationFailure() {
|
||||||
|
operationErrorKey = "account.error.appleAuthorization"
|
||||||
|
}
|
||||||
|
|
||||||
|
func dismissOperationError() {
|
||||||
|
operationErrorKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performAccountRefresh(for accountID: UUID) async {
|
||||||
|
isRefreshingAccountData = true
|
||||||
|
accountRefreshErrorKey = nil
|
||||||
|
defer { isRefreshingAccountData = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let cachedSnapshot: AccountCenterSnapshot?
|
||||||
|
if case let .loaded(snapshot) = snapshotPhase {
|
||||||
|
cachedSnapshot = snapshot
|
||||||
|
} else {
|
||||||
|
cachedSnapshot = nil
|
||||||
|
}
|
||||||
|
let snapshot = try await centerService.loadAccountCenter(
|
||||||
|
cachedSnapshot: cachedSnapshot
|
||||||
|
)
|
||||||
|
guard self.accountID == accountID,
|
||||||
|
snapshot.account.accountID == accountID else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshotPhase = .loaded(snapshot)
|
||||||
|
sessionPhase = .signedIn(snapshot.account)
|
||||||
|
lastAccountRefreshAt = now()
|
||||||
|
} catch {
|
||||||
|
guard self.accountID == accountID else { return }
|
||||||
|
let messageKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.load"
|
||||||
|
)
|
||||||
|
accountRefreshErrorKey = messageKey
|
||||||
|
if case .loaded = snapshotPhase {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
snapshotPhase = .failed(messageKey: messageKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearAccountRefreshState() {
|
||||||
|
accountRefreshTask?.cancel()
|
||||||
|
accountRefreshTask = nil
|
||||||
|
lastAccountRefreshAt = nil
|
||||||
|
isRefreshingAccountData = false
|
||||||
|
accountRefreshErrorKey = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func redeemPendingReferralIfNeeded() async {
|
||||||
|
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||||
|
|
||||||
|
operation = .redeemingReferral
|
||||||
|
do {
|
||||||
|
try await centerService.redeemReferral(code: code)
|
||||||
|
pendingReferralStore.clear()
|
||||||
|
pendingReferralCode = nil
|
||||||
|
} catch {
|
||||||
|
pendingReferralCode = code
|
||||||
|
operationErrorKey = errorMessageKey(
|
||||||
|
for: error,
|
||||||
|
fallback: "account.error.redeemReferral"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
operation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorMessageKey(for error: Error, fallback: String) -> String {
|
||||||
|
if error as? AccountIntegrationError == .unavailable {
|
||||||
|
return "account.error.unavailable"
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,635 @@
|
|||||||
|
// LiveAccountServices.swift
|
||||||
|
// OSGKeyboard · Main App
|
||||||
|
//
|
||||||
|
// Adapts the host-private account protocol to the account-center UI. Account
|
||||||
|
// sessions never enter App Group storage; only scope-limited gateway grants do.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
enum LiveAccountDependencyFactory {
|
||||||
|
static func make(bundle: Bundle = .main) -> AccountDependencies {
|
||||||
|
guard let accessGroup = bundle.object(
|
||||||
|
forInfoDictionaryKey: "OSGPrivateKeychainAccessGroup"
|
||||||
|
) as? String,
|
||||||
|
let descriptor = try? HostPrivateAccountKeychainDescriptor(
|
||||||
|
accessGroup: accessGroup
|
||||||
|
) else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
|
||||||
|
let apiClient = AccountAPIClient(sessionVault: sessionVault)
|
||||||
|
let integrity = DeviceIntegrityCoordinator(
|
||||||
|
apiClient: apiClient,
|
||||||
|
keyStateStore: sessionVault
|
||||||
|
)
|
||||||
|
let grantStore = GatewayGrantKeychainStore()
|
||||||
|
let grants = GatewayGrantCoordinator(store: grantStore)
|
||||||
|
let service = LiveAccountService(
|
||||||
|
apiClient: apiClient,
|
||||||
|
integrity: integrity,
|
||||||
|
grants: grants,
|
||||||
|
grantStore: grantStore,
|
||||||
|
configuration: AppGroupStore()
|
||||||
|
)
|
||||||
|
return AccountDependencies(sessionService: service, centerService: service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AccountCenterSnapshotLoader {
|
||||||
|
private let apiClient: AccountAPIClient
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
|
||||||
|
init(apiClient: AccountAPIClient) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(
|
||||||
|
cachedSnapshot: AccountCenterSnapshot?
|
||||||
|
) async throws -> AccountCenterSnapshot {
|
||||||
|
async let account = apiClient.account()
|
||||||
|
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
|
||||||
|
async let profile = optionalResource(
|
||||||
|
ReferralProfileDTO.self,
|
||||||
|
.referralProfile,
|
||||||
|
diagnosticName: "referral-profile"
|
||||||
|
)
|
||||||
|
async let referrals = optionalResource(
|
||||||
|
[ReferralBindingDTO].self,
|
||||||
|
.referrals(limit: 50),
|
||||||
|
diagnosticName: "referrals"
|
||||||
|
)
|
||||||
|
async let campaigns = optionalResource(
|
||||||
|
[ReferralCampaignDTO].self,
|
||||||
|
.referralCampaigns,
|
||||||
|
diagnosticName: "referral-campaigns"
|
||||||
|
)
|
||||||
|
|
||||||
|
let core: (OSGAccount, CreditBalanceDTO)
|
||||||
|
do {
|
||||||
|
core = try await (account, balance)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"account refresh core failed error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
let optionalValues = await (profile, referrals, campaigns)
|
||||||
|
let invitationCode = await resolvedInvitationCode(
|
||||||
|
profile: optionalValues.0,
|
||||||
|
cachedProfile: cachedSnapshot?.referralProfile
|
||||||
|
)
|
||||||
|
let campaign = optionalValues.2?.first {
|
||||||
|
$0.id == optionalValues.0?.code?.campaignId
|
||||||
|
} ?? optionalValues.2?.first
|
||||||
|
let referralProfile = if optionalValues.0 == nil,
|
||||||
|
let cachedProfile = cachedSnapshot?.referralProfile {
|
||||||
|
cachedProfile
|
||||||
|
} else {
|
||||||
|
AccountReferralProfile(
|
||||||
|
code: invitationCode,
|
||||||
|
boundCode: cachedSnapshot?.referralProfile.boundCode,
|
||||||
|
inviterRewardCredits: campaign?.inviterRewardCredits
|
||||||
|
?? cachedSnapshot?.referralProfile.inviterRewardCredits,
|
||||||
|
inviteeRewardCredits: campaign?.inviteeRewardCredits
|
||||||
|
?? cachedSnapshot?.referralProfile.inviteeRewardCredits
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let loadedReferrals = optionalValues.1?.map {
|
||||||
|
AccountReferral(
|
||||||
|
id: UUID(),
|
||||||
|
status: Self.referralStatus($0.rewardStatus),
|
||||||
|
createdAtEpochSeconds: Self.epochSeconds($0.boundAt),
|
||||||
|
rewardCredits: nil
|
||||||
|
)
|
||||||
|
} ?? cachedSnapshot?.referrals ?? []
|
||||||
|
|
||||||
|
return AccountCenterSnapshot(
|
||||||
|
account: AccountSession(
|
||||||
|
accountID: core.0.id,
|
||||||
|
createdAtEpochSeconds: core.0.createdAtEpochSeconds,
|
||||||
|
displayName: core.0.displayName
|
||||||
|
),
|
||||||
|
credits: AccountCreditSummary(
|
||||||
|
balance: core.1.balance,
|
||||||
|
usedCredits: core.1.lifetimeUsed ?? 0
|
||||||
|
),
|
||||||
|
referralProfile: referralProfile,
|
||||||
|
referrals: loadedReferrals
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resource<Value: Decodable & Sendable>(
|
||||||
|
_ type: Value.Type,
|
||||||
|
_ resource: AccountAuthorizedResource
|
||||||
|
) async throws -> Value {
|
||||||
|
let data = try await apiClient.authorizedResourceData(resource)
|
||||||
|
return try decoder.decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func optionalResource<Value: Decodable & Sendable>(
|
||||||
|
_ type: Value.Type,
|
||||||
|
_ resource: AccountAuthorizedResource,
|
||||||
|
diagnosticName: String
|
||||||
|
) async -> Value? {
|
||||||
|
do {
|
||||||
|
return try await self.resource(type, resource)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"account refresh optional=\(diagnosticName) fallback=cache "
|
||||||
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedInvitationCode(
|
||||||
|
profile: ReferralProfileDTO?,
|
||||||
|
cachedProfile: AccountReferralProfile?
|
||||||
|
) async -> String? {
|
||||||
|
if let code = profile?.code?.code ?? cachedProfile?.code {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let data = try await apiClient.authorizedResourceData(.createReferralCode)
|
||||||
|
return try decoder.decode(ReferralCodeDTO.self, from: data).code
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"account refresh optional=referral-code fallback=cache "
|
||||||
|
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
return cachedProfile?.code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func referralStatus(_ value: String) -> AccountReferralStatus {
|
||||||
|
switch value.uppercased() {
|
||||||
|
case "REWARDED":
|
||||||
|
return .rewarded
|
||||||
|
case "INELIGIBLE_BUDGET":
|
||||||
|
return .ineligible
|
||||||
|
default:
|
||||||
|
return .pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func epochSeconds(_ value: String) -> Int64? {
|
||||||
|
let fractional = ISO8601DateFormatter()
|
||||||
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let standard = ISO8601DateFormatter()
|
||||||
|
standard.formatOptions = [.withInternetDateTime]
|
||||||
|
let date = fractional.date(from: value) ?? standard.date(from: value)
|
||||||
|
return date.map { Int64($0.timeIntervalSince1970) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
|
||||||
|
private let apiClient: AccountAPIClient
|
||||||
|
private let accountCenterLoader: AccountCenterSnapshotLoader
|
||||||
|
private let integrity: DeviceIntegrityCoordinator
|
||||||
|
private let grants: GatewayGrantCoordinator
|
||||||
|
private let grantStore: any GatewayGrantCredentialStore
|
||||||
|
private let configuration: AppGroupStore
|
||||||
|
private let decoder = JSONDecoder()
|
||||||
|
private let encoder = JSONEncoder()
|
||||||
|
|
||||||
|
init(
|
||||||
|
apiClient: AccountAPIClient,
|
||||||
|
integrity: DeviceIntegrityCoordinator,
|
||||||
|
grants: GatewayGrantCoordinator,
|
||||||
|
grantStore: any GatewayGrantCredentialStore,
|
||||||
|
configuration: AppGroupStore
|
||||||
|
) {
|
||||||
|
self.apiClient = apiClient
|
||||||
|
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
|
||||||
|
self.integrity = integrity
|
||||||
|
self.grants = grants
|
||||||
|
self.grantStore = grantStore
|
||||||
|
self.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSession() async throws -> AccountSession? {
|
||||||
|
guard let cachedSession = try await apiClient.currentSession() else { return nil }
|
||||||
|
let account: OSGAccount?
|
||||||
|
do {
|
||||||
|
account = try await apiClient.account()
|
||||||
|
} catch let error as AccountAPIError {
|
||||||
|
switch error {
|
||||||
|
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
account = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
account = nil
|
||||||
|
}
|
||||||
|
let session = try await apiClient.currentSession() ?? cachedSession
|
||||||
|
await synchronizeManagedGrant()
|
||||||
|
return uiSession(
|
||||||
|
session,
|
||||||
|
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
||||||
|
displayName: account?.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||||
|
let credential = AppleSignInCredential(
|
||||||
|
identityToken: payload.identityToken,
|
||||||
|
authorizationCode: payload.authorizationCode
|
||||||
|
)
|
||||||
|
let evidence: DeviceIntegrityEvidence
|
||||||
|
do {
|
||||||
|
evidence = try await integrity.evidenceForAppleSignIn(
|
||||||
|
credential: credential,
|
||||||
|
rawNonce: payload.nonce
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"signIn failed stage=integrity error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
OSGDiag.log(
|
||||||
|
"signIn integrity ready deviceCheck=\(evidence.deviceCheckToken == nil ? 0 : 1) "
|
||||||
|
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
let session: OSGKeyboardHostSupport.AccountSession
|
||||||
|
do {
|
||||||
|
session = try await apiClient.signInWithApple(
|
||||||
|
AppleSignInRequest(
|
||||||
|
identityToken: payload.identityToken,
|
||||||
|
authorizationCode: payload.authorizationCode,
|
||||||
|
nonce: payload.nonce,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
deviceCheckToken: evidence.deviceCheckToken,
|
||||||
|
appAttest: evidence.appAttest
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await synchronizeManagedGrant()
|
||||||
|
let account: OSGAccount
|
||||||
|
do {
|
||||||
|
account = try await apiClient.account()
|
||||||
|
} catch {
|
||||||
|
OSGDiag.log(
|
||||||
|
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
OSGDiag.log("signIn completed", category: "account")
|
||||||
|
return uiSession(
|
||||||
|
session,
|
||||||
|
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||||
|
displayName: account.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signOut() async throws {
|
||||||
|
await revokeManagedGrantIfPossible()
|
||||||
|
do {
|
||||||
|
try await apiClient.logout()
|
||||||
|
} catch {
|
||||||
|
try? await grants.clearGrant()
|
||||||
|
configuration.setCredentialSource(.byok)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
try? await grants.clearGrant()
|
||||||
|
configuration.setCredentialSource(.byok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||||
|
try await apiClient.deleteAccount(
|
||||||
|
identityToken: payload.identityToken,
|
||||||
|
authorizationCode: payload.authorizationCode,
|
||||||
|
nonce: payload.nonce
|
||||||
|
)
|
||||||
|
try? await grants.clearGrant()
|
||||||
|
await integrity.clearLocalKeyState()
|
||||||
|
configuration.setCredentialSource(.byok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareManagedGateway() async throws {
|
||||||
|
guard try await apiClient.currentSession() != nil else {
|
||||||
|
throw AccountAPIError.sessionUnavailable
|
||||||
|
}
|
||||||
|
try await ensureManagedGrant()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearManagedGateway() async {
|
||||||
|
await revokeManagedGrantIfPossible()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||||
|
try await accountCenterLoader.load(cachedSnapshot: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAccountCenter(
|
||||||
|
cachedSnapshot: AccountCenterSnapshot?
|
||||||
|
) async throws -> AccountCenterSnapshot {
|
||||||
|
try await accountCenterLoader.load(cachedSnapshot: cachedSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||||
|
let account = try await apiClient.updateAccount(displayName: displayName)
|
||||||
|
return AccountSession(
|
||||||
|
accountID: account.id,
|
||||||
|
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||||
|
displayName: account.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createReferralCode() async throws -> String {
|
||||||
|
let data = try await apiClient.authorizedResourceData(.createReferralCode)
|
||||||
|
return try decoder.decode(ReferralCodeDTO.self, from: data).code
|
||||||
|
}
|
||||||
|
|
||||||
|
func redeemReferral(code: String) async throws {
|
||||||
|
struct Request: Encodable { let code: String }
|
||||||
|
let body = try encoder.encode(Request(code: code))
|
||||||
|
_ = try await apiClient.authorizedResourceData(.redeemReferral, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||||
|
let products = try await resource([StoreKitProductDTO].self, .storeKitProducts)
|
||||||
|
return products.map {
|
||||||
|
AccountCreditProduct(productID: $0.productId, credits: $0.credits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||||
|
let request = StoreKitTransactionRequestDTO(signedTransaction: signedTransaction)
|
||||||
|
let data = try await apiClient.authorizedResourceData(
|
||||||
|
.submitStoreKitTransaction,
|
||||||
|
body: try encoder.encode(request)
|
||||||
|
)
|
||||||
|
let purchase = try decoder.decode(StoreKitPurchaseDTO.self, from: data)
|
||||||
|
return AccountCreditPurchase(
|
||||||
|
transactionID: purchase.transactionId,
|
||||||
|
productID: purchase.productId,
|
||||||
|
creditsGranted: purchase.creditsGranted,
|
||||||
|
balanceAfter: purchase.balanceAfter,
|
||||||
|
replayed: purchase.replayed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCreditPurchaseHistory(
|
||||||
|
limit: Int,
|
||||||
|
cursor: String?
|
||||||
|
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||||
|
let data = try await apiClient.authorizedResourceData(
|
||||||
|
.storeKitTransactions(limit: limit, cursor: cursor)
|
||||||
|
)
|
||||||
|
let response = try decoder.decode(
|
||||||
|
StoreKitTransactionHistoryResponseDTO.self,
|
||||||
|
from: data
|
||||||
|
)
|
||||||
|
let items = try response.items.map { item in
|
||||||
|
guard let purchasedAt = Self.storeKitPurchaseDate(item.purchasedAt),
|
||||||
|
let status = AccountCreditPurchaseStatus(rawValue: item.status) else {
|
||||||
|
throw AccountAPIError.decoding
|
||||||
|
}
|
||||||
|
return AccountCreditPurchaseRecord(
|
||||||
|
transactionID: item.transactionId,
|
||||||
|
productID: item.productId,
|
||||||
|
creditsGranted: item.creditsGranted,
|
||||||
|
balanceAfter: item.balanceAfter,
|
||||||
|
purchasedAt: purchasedAt,
|
||||||
|
status: status
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return AccountCreditPurchaseHistoryPage(
|
||||||
|
items: items,
|
||||||
|
nextCursor: response.nextCursor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resource<Value: Decodable & Sendable>(
|
||||||
|
_ type: Value.Type,
|
||||||
|
_ resource: AccountAuthorizedResource
|
||||||
|
) async throws -> Value {
|
||||||
|
let data = try await apiClient.authorizedResourceData(resource)
|
||||||
|
return try decoder.decode(type, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func storeKitPurchaseDate(_ value: String) -> Date? {
|
||||||
|
let fractional = ISO8601DateFormatter()
|
||||||
|
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
let standard = ISO8601DateFormatter()
|
||||||
|
standard.formatOptions = [.withInternetDateTime]
|
||||||
|
return fractional.date(from: value) ?? standard.date(from: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func synchronizeManagedGrant() async {
|
||||||
|
if configuration.credentialSource == .managed {
|
||||||
|
do {
|
||||||
|
try await ensureManagedGrant()
|
||||||
|
} catch {
|
||||||
|
// Keep the user's explicit billing choice. A transient grant,
|
||||||
|
// network, or balance failure must surface as a managed-service
|
||||||
|
// error instead of silently switching requests to a BYOK key.
|
||||||
|
OSGDiag.log(
|
||||||
|
"managedGrant synchronization failed "
|
||||||
|
+ "errorCategory=\(String(reflecting: type(of: error)))",
|
||||||
|
category: "account"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try? await grants.clearGrant()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureManagedGrant() async throws {
|
||||||
|
let expected = ManagedGatewayScopePolicy.scopes(
|
||||||
|
engineMode: configuration.engineMode
|
||||||
|
)
|
||||||
|
if let existing = try await grantStore.load(),
|
||||||
|
existing.scopes == expected,
|
||||||
|
existing.hasUsableRefreshToken() {
|
||||||
|
_ = try await grants.accessToken(for: .polish)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await revokeManagedGrantIfPossible()
|
||||||
|
let idempotencyKey = UUID().uuidString
|
||||||
|
let accessToken = try await apiClient.accessTokenForAuthorizedRequest()
|
||||||
|
do {
|
||||||
|
_ = try await grants.createGrant(
|
||||||
|
accountAccessToken: accessToken,
|
||||||
|
scopes: expected,
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
)
|
||||||
|
} catch ManagedGatewayError.invalidGrant {
|
||||||
|
let replacement = try await apiClient.refreshAccessToken(
|
||||||
|
afterUnauthorizedAccessToken: accessToken
|
||||||
|
)
|
||||||
|
_ = try await grants.createGrant(
|
||||||
|
accountAccessToken: replacement,
|
||||||
|
scopes: expected,
|
||||||
|
idempotencyKey: idempotencyKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func revokeManagedGrantIfPossible() async {
|
||||||
|
if let existing = try? await grantStore.load(),
|
||||||
|
let grantId = UUID(uuidString: existing.grantId) {
|
||||||
|
_ = try? await apiClient.authorizedResourceData(
|
||||||
|
.revokeGatewayGrant(grantId)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try? await grants.clearGrant()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uiSession(
|
||||||
|
_ session: OSGKeyboardHostSupport.AccountSession,
|
||||||
|
createdAtEpochSeconds: Int64,
|
||||||
|
displayName: String?
|
||||||
|
) -> AccountSession {
|
||||||
|
AccountSession(
|
||||||
|
accountID: session.accountId,
|
||||||
|
createdAtEpochSeconds: createdAtEpochSeconds,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum AccountDiagnostic {
|
||||||
|
static func code(for error: Error) -> String {
|
||||||
|
guard let error = error as? AccountAPIError else {
|
||||||
|
return String(describing: type(of: error))
|
||||||
|
}
|
||||||
|
switch error {
|
||||||
|
case .invalidRequest(let message):
|
||||||
|
return invalidRequestCode(for: message)
|
||||||
|
case .unauthorized:
|
||||||
|
return "unauthorized"
|
||||||
|
case .refreshTokenReuse:
|
||||||
|
return "refresh-token-reuse"
|
||||||
|
case .externalServiceUnavailable(let message):
|
||||||
|
return unavailableServiceCode(for: message)
|
||||||
|
case .conflict:
|
||||||
|
return "conflict"
|
||||||
|
case .rateLimited:
|
||||||
|
return "rate-limited"
|
||||||
|
case let .server(statusCode, code, _):
|
||||||
|
return "server-\(statusCode)-\(code)"
|
||||||
|
case .transport:
|
||||||
|
return "transport"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "invalid-response"
|
||||||
|
case .decoding:
|
||||||
|
return "decoding"
|
||||||
|
case .secureStorage:
|
||||||
|
return "secure-storage"
|
||||||
|
case .sessionUnavailable:
|
||||||
|
return "session-unavailable"
|
||||||
|
case .appleAuthorization:
|
||||||
|
return "apple-authorization"
|
||||||
|
case .integrityUnavailable:
|
||||||
|
return "integrity-unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func invalidRequestCode(for message: String) -> String {
|
||||||
|
switch message {
|
||||||
|
case "DeviceCheck verification failed":
|
||||||
|
return "device-check-rejected"
|
||||||
|
case "App Attest verification failed":
|
||||||
|
return "app-attest-rejected"
|
||||||
|
case "identityToken exceeds the maximum length":
|
||||||
|
return "identity-token-too-large"
|
||||||
|
case "authorizationCode exceeds the maximum length":
|
||||||
|
return "authorization-code-too-large"
|
||||||
|
case "nonce exceeds the maximum length":
|
||||||
|
return "nonce-too-large"
|
||||||
|
default:
|
||||||
|
return "invalid-request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func unavailableServiceCode(for message: String) -> String {
|
||||||
|
switch message {
|
||||||
|
case "DeviceCheck is temporarily unavailable":
|
||||||
|
return "device-check-unavailable"
|
||||||
|
case "App Attest is temporarily unavailable":
|
||||||
|
return "app-attest-unavailable"
|
||||||
|
case "Apple identity verification is temporarily unavailable":
|
||||||
|
return "apple-identity-unavailable"
|
||||||
|
case "Apple token service is temporarily unavailable":
|
||||||
|
return "apple-token-service-unavailable"
|
||||||
|
default:
|
||||||
|
return "external-service-unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CreditBalanceDTO: Decodable, Sendable {
|
||||||
|
let balance: Int64
|
||||||
|
let lifetimeUsed: Int64?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReferralCodeDTO: Decodable, Sendable {
|
||||||
|
let code: String
|
||||||
|
let campaignId: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReferralProfileDTO: Decodable, Sendable {
|
||||||
|
let code: ReferralCodeDTO?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReferralCampaignDTO: Decodable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let inviterRewardCredits: Int64
|
||||||
|
let inviteeRewardCredits: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ReferralBindingDTO: Decodable, Sendable {
|
||||||
|
let boundAt: String
|
||||||
|
let rewardStatus: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoreKitProductDTO: Decodable, Sendable {
|
||||||
|
let productId: String
|
||||||
|
let credits: Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoreKitTransactionRequestDTO: Encodable, Sendable {
|
||||||
|
let signedTransaction: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoreKitPurchaseDTO: Decodable, Sendable {
|
||||||
|
let transactionId: String
|
||||||
|
let productId: String
|
||||||
|
let creditsGranted: Int64
|
||||||
|
let balanceAfter: Int64
|
||||||
|
let replayed: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoreKitTransactionHistoryResponseDTO: Decodable, Sendable {
|
||||||
|
let items: [StoreKitTransactionHistoryItemDTO]
|
||||||
|
let nextCursor: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct StoreKitTransactionHistoryItemDTO: Decodable, Sendable {
|
||||||
|
let transactionId: String
|
||||||
|
let productId: String
|
||||||
|
let creditsGranted: Int64
|
||||||
|
let balanceAfter: Int64
|
||||||
|
let purchasedAt: String
|
||||||
|
let status: String
|
||||||
|
}
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
// running a TestFlight build with a stripped entitlement, and a friendly
|
// running a TestFlight build with a stripped entitlement, and a friendly
|
||||||
// screen is much better than a crash loop.
|
// screen is much better than a crash loop.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct AppGroupErrorView: View {
|
struct AppGroupErrorView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
#if DEBUG
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
/// Deterministic host for simulator UI tests of the real assistant keyboard.
|
||||||
|
/// It exercises gesture routing and closed UI states without requiring a
|
||||||
|
/// keyboard-extension process, microphone permission, ASR, or an LLM.
|
||||||
|
struct AssistantKeyboardUITestHarness: View {
|
||||||
|
private enum Scenario: String {
|
||||||
|
case idle
|
||||||
|
case completed
|
||||||
|
case pending
|
||||||
|
case skillFailure
|
||||||
|
case skills
|
||||||
|
case search
|
||||||
|
}
|
||||||
|
|
||||||
|
@StateObject private var state = KeyboardState()
|
||||||
|
@StateObject private var typing = TypingSessionController()
|
||||||
|
@State private var configured = false
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
init() {
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private let scenario: Scenario = {
|
||||||
|
let prefix = "--assistant-state="
|
||||||
|
let raw = ProcessInfo.processInfo.arguments
|
||||||
|
.first(where: { $0.hasPrefix(prefix) })?
|
||||||
|
.dropFirst(prefix.count)
|
||||||
|
return raw.flatMap { Scenario(rawValue: String($0)) } ?? .idle
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { proxy in
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
AIKeyboardView(
|
||||||
|
state: state,
|
||||||
|
typing: typing,
|
||||||
|
onInsert: { _ in }
|
||||||
|
)
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
configure(width: proxy.size.width)
|
||||||
|
}
|
||||||
|
.onChange(of: proxy.size.width) { _, width in
|
||||||
|
configureLayout(width: width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor.ignoresSafeArea())
|
||||||
|
.onDisappear {
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||||
|
AIKeyboardView.debugKeepsSkillTip = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var backgroundColor: Color {
|
||||||
|
colorScheme == .dark ? Palette.dark.background : Palette.light.background
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configure(width: CGFloat) {
|
||||||
|
configureLayout(width: width)
|
||||||
|
guard !configured else { return }
|
||||||
|
configured = true
|
||||||
|
|
||||||
|
state.surface = .voice
|
||||||
|
state.phase = .idle
|
||||||
|
state.aiServiceAvailable = true
|
||||||
|
state.micDisabled = false
|
||||||
|
state.returnKeyRole = .send
|
||||||
|
|
||||||
|
let keyboardState = state
|
||||||
|
state.tapMic = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
if case .recording = keyboardState.phase {
|
||||||
|
keyboardState.phase = .idle
|
||||||
|
} else {
|
||||||
|
keyboardState.phase = .recording
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.tapAIMic = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
if keyboardState.aiSession.phase == .listening {
|
||||||
|
if let utteranceID = keyboardState.aiSession.activeUtteranceID {
|
||||||
|
keyboardState.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyboardState.aiSession.enter()
|
||||||
|
let utteranceID = UUID()
|
||||||
|
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
keyboardState.aiSession.beginListening(utteranceID: utteranceID)
|
||||||
|
}
|
||||||
|
state.performAssistantFieldAction = { [weak keyboardState] in
|
||||||
|
keyboardState?.assistantActionAvailable = false
|
||||||
|
}
|
||||||
|
state.undoLastInsertion = { [weak keyboardState] in
|
||||||
|
keyboardState?.undoAvailable = false
|
||||||
|
}
|
||||||
|
state.beginEditLastInput = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
let reference = EditableInputReference(
|
||||||
|
displayText: "Original dictated input",
|
||||||
|
insertedText: "Original dictated input",
|
||||||
|
postInsertionFingerprint: nil,
|
||||||
|
extensionInstanceID: UUID()
|
||||||
|
)
|
||||||
|
keyboardState.editSession = .listening(EditSessionSource(reference: reference))
|
||||||
|
keyboardState.phase = .recording
|
||||||
|
}
|
||||||
|
state.stopEditListening = { [weak keyboardState] in
|
||||||
|
guard let keyboardState,
|
||||||
|
let source = keyboardState.editSession.source else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keyboardState.editSession = .review(
|
||||||
|
EditReview(
|
||||||
|
source: source,
|
||||||
|
resultText: "Edited dictated input",
|
||||||
|
utteranceID: UUID()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
keyboardState.phase = .processing
|
||||||
|
}
|
||||||
|
state.confirmEditResult = { [weak keyboardState] in
|
||||||
|
keyboardState?.editSession = .inactive
|
||||||
|
keyboardState?.phase = .idle
|
||||||
|
}
|
||||||
|
state.submitAIHint = { [weak keyboardState] _ in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
let utteranceID = UUID()
|
||||||
|
keyboardState.aiSession.enter()
|
||||||
|
keyboardState.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
keyboardState.aiSession.beginGenerating(
|
||||||
|
question: "Deterministic hint",
|
||||||
|
utteranceID: utteranceID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch scenario {
|
||||||
|
case .idle:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
case .completed:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.undoAvailable = true
|
||||||
|
state.editAvailable = true
|
||||||
|
state.assistantActionAvailable = true
|
||||||
|
case .pending:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
let utteranceID = UUID()
|
||||||
|
state.aiSession.enter()
|
||||||
|
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||||
|
state.aiSession.receiveAnswer(
|
||||||
|
"A retained answer that requires explicit insertion.",
|
||||||
|
utteranceID: utteranceID
|
||||||
|
)
|
||||||
|
state.confirmPendingAIAnswer = { [weak keyboardState] in
|
||||||
|
guard let keyboardState else { return }
|
||||||
|
keyboardState.aiSession.markAnswerInserted(offersSend: true)
|
||||||
|
keyboardState.undoAvailable = true
|
||||||
|
keyboardState.editAvailable = true
|
||||||
|
keyboardState.assistantActionAvailable = true
|
||||||
|
}
|
||||||
|
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||||
|
keyboardState?.aiSession.discardReadyAnswer()
|
||||||
|
}
|
||||||
|
case .skillFailure:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.skillTipText = "Skill failed"
|
||||||
|
case .skills:
|
||||||
|
AIKeyboardView.debugPreviewSkills = AIClipboardSkillCatalog.catalog
|
||||||
|
state.undoAvailable = true
|
||||||
|
state.editAvailable = true
|
||||||
|
case .search:
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
|
state.returnKeyRole = .search
|
||||||
|
state.assistantActionAvailable = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureLayout(width: CGFloat) {
|
||||||
|
let isIPad = UIDevice.current.userInterfaceIdiom == .pad
|
||||||
|
state.layoutWidth = width
|
||||||
|
state.usesIPadLayoutMetrics = isIPad
|
||||||
|
state.showsSystemGlobeKey = isIPad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical-device harness that gives XCUITest a real user tap for PiP.
|
||||||
|
///
|
||||||
|
/// `devicectl process launch` is not a user interaction, so iOS may silently
|
||||||
|
/// ignore a programmatic foreground PiP request even when AVKit reports it as
|
||||||
|
/// possible. This harness isolates the production controller behind one tap.
|
||||||
|
struct FlowPiPDeviceUITestHarness: View {
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@StateObject private var flowManager = FlowSessionManager()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Button("Start PiP") {
|
||||||
|
flowManager.startSession(reason: "uiTest.userTap")
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("pip.start")
|
||||||
|
.disabled(flowManager.isStarting || flowManager.isActive)
|
||||||
|
|
||||||
|
Text(statusIdentifier)
|
||||||
|
.accessibilityIdentifier(statusIdentifier)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background {
|
||||||
|
FlowPiPHostView { view in
|
||||||
|
flowManager.attachPiPHostView(view)
|
||||||
|
}
|
||||||
|
.frame(width: 64, height: 36)
|
||||||
|
.opacity(0.02)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
flowManager.setAppForeground(scenePhase == .active)
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
flowManager.handleScenePhase(phase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusIdentifier: String {
|
||||||
|
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||||
|
return "pip.status.ready"
|
||||||
|
}
|
||||||
|
if flowManager.sessionWarning != nil {
|
||||||
|
return "pip.status.failed"
|
||||||
|
}
|
||||||
|
if flowManager.isStarting {
|
||||||
|
return "pip.status.starting"
|
||||||
|
}
|
||||||
|
return "pip.status.idle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Shared edit / selected badges for style and skill cards.
|
// Shared edit / selected badges for style and skill cards.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum CatalogCardChrome {
|
enum CatalogCardChrome {
|
||||||
static let badgeSize: CGFloat = 30
|
static let badgeSize: CGFloat = 30
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
|
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ClipboardHistoryDemoView: View {
|
struct ClipboardHistoryDemoView: View {
|
||||||
private enum Layout {
|
private enum Layout {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// `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 7-day chart card.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct HomeUsageStatsSection<Header: View>: View {
|
struct HomeUsageStatsSection<Header: View>: View {
|
||||||
let layout: UsageStatsClusterLayout
|
let layout: UsageStatsClusterLayout
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// Google Material Icons (bundled MaterialIcons-Regular.ttf).
|
// Google Material Icons (bundled MaterialIcons-Regular.ttf).
|
||||||
// Resolves the PostScript name at runtime and falls back to SF Symbols.
|
// Resolves the PostScript name at runtime and falls back to SF Symbols.
|
||||||
|
|
||||||
|
import CoreText
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import CoreText
|
|
||||||
|
|
||||||
enum MaterialIconName {
|
enum MaterialIconName {
|
||||||
case keyboard
|
case keyboard
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// fill inside that capsule (Photos-style), not a second glass layer.
|
// fill inside that capsule (Photos-style), not a second glass layer.
|
||||||
// History + dictionary live as Home cards (not dock tabs).
|
// History + dictionary live as Home cards (not dock tabs).
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum AppTab: Int, CaseIterable {
|
enum AppTab: Int, CaseIterable {
|
||||||
case keyboard
|
case keyboard
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Reusable privacy / data-flow disclosure blocks for Settings and Onboarding.
|
// Reusable privacy / data-flow disclosure blocks for Settings and Onboarding.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
/// Card-style block for Full Access and similar permission explanations.
|
/// Card-style block for Full Access and similar permission explanations.
|
||||||
struct PrivacyInfoCard: View {
|
struct PrivacyInfoCard: View {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
||||||
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Preference
|
// MARK: - Preference
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
//
|
//
|
||||||
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
|
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Layout metrics
|
// MARK: - Layout metrics
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// Launch with `--edit-demo`. Not shipped in Release.
|
// Launch with `--edit-demo`. Not shipped in Release.
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct EditDemoView: View {
|
struct EditDemoView: View {
|
||||||
private static let originalText = "明天下午三点开会讨论方案"
|
private static let originalText = "明天下午三点开会讨论方案"
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
// EnginePickerSection.swift
|
// EnginePickerSection.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
//
|
//
|
||||||
// Engine picker — Local (on-device ASR) vs
|
// Global AI service source. ASR locality is selected separately in the
|
||||||
// Cloud (ASR + optional LLM polish via the user's API).
|
// speech-recognition provider list when the user supplies credentials.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct EnginePickerSection<ConfigurationRows: View>: View {
|
struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||||
|
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
|
@State private var showsManagedCloudConsent = false
|
||||||
private let configurationRows: ConfigurationRows
|
private let configurationRows: ConfigurationRows
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -22,38 +24,53 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CardSection("settings.engine.title") {
|
CardSection("settings.aiService.title") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
engineOptionRow(
|
serviceOptionRow(
|
||||||
id: "local",
|
source: .managed,
|
||||||
title: AppL10n.string("settings.engine.local.title"),
|
title: AppL10n.string("settings.aiService.credits.title"),
|
||||||
subtitle: localSubtitle
|
subtitle: AppL10n.string(
|
||||||
|
accountSession.isSignedIn
|
||||||
|
? "settings.aiService.credits.subtitle"
|
||||||
|
: "settings.aiService.credits.signIn"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
engineOptionRow(
|
serviceOptionRow(
|
||||||
id: "cloud",
|
source: .byok,
|
||||||
title: AppL10n.string("settings.engine.cloud.title"),
|
title: AppL10n.string("settings.aiService.byok.title"),
|
||||||
subtitle: AppL10n.string("settings.engine.cloud.subtitle")
|
subtitle: AppL10n.string("settings.aiService.byok.subtitle")
|
||||||
)
|
)
|
||||||
configurationRows
|
if config.credentialSource == .byok {
|
||||||
|
configurationRows
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
"settings.aiService.credits.consent.title",
|
||||||
|
isPresented: $showsManagedCloudConsent
|
||||||
|
) {
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
|
Button("settings.aiService.credits.consent.accept") {
|
||||||
|
config.hasAcknowledgedCloudSharing = true
|
||||||
|
activateManagedService()
|
||||||
|
}
|
||||||
|
.accessibilityIdentifier("settings.aiService.credits.consent.accept")
|
||||||
|
} message: {
|
||||||
|
Text("settings.aiService.credits.consent.message")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var localSubtitle: String {
|
private func serviceOptionRow(
|
||||||
AppL10n.string("settings.engine.local.legacy")
|
source: CredentialSource,
|
||||||
}
|
|
||||||
|
|
||||||
private func engineOptionRow(
|
|
||||||
id: String,
|
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String
|
subtitle: String
|
||||||
) -> some View {
|
) -> some View {
|
||||||
let isSelected = config.engineMode == id
|
let isSelected = config.credentialSource == source
|
||||||
return Button {
|
return Button {
|
||||||
guard config.engineMode != id else { return }
|
guard config.credentialSource != source else { return }
|
||||||
selectEngine(id)
|
selectSource(source)
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: Spacing.sm) {
|
HStack(spacing: Spacing.sm) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -75,17 +92,41 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
|||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("settings.aiService.\(source.rawValue)")
|
||||||
|
.accessibilityValue(isSelected ? "selected" : "notSelected")
|
||||||
|
.disabled(
|
||||||
|
accountSession.operation != nil
|
||||||
|
|| (source == .managed && !accountSession.isSignedIn)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectEngine(_ id: String) {
|
private func selectSource(_ source: CredentialSource) {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
switch source {
|
||||||
config.engineMode = id
|
case .managed:
|
||||||
if id == "cloud" {
|
if config.hasAcknowledgedCloudSharing {
|
||||||
config.modeId = "polish"
|
activateManagedService()
|
||||||
|
} else {
|
||||||
|
showsManagedCloudConsent = true
|
||||||
}
|
}
|
||||||
|
case .byok:
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
config.credentialSource = .byok
|
||||||
|
}
|
||||||
|
Task { await accountSession.clearManagedGateway() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func activateManagedService() {
|
||||||
|
Task {
|
||||||
|
if await accountSession.prepareManagedGateway() {
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
config.engineMode = "cloud"
|
||||||
|
config.modeId = "polish"
|
||||||
|
config.credentialSource = .managed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EnginePickerSection where ConfigurationRows == EmptyView {
|
extension EnginePickerSection where ConfigurationRows == EmptyView {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// In-app GitHub Issues page. Reached from Settings → About.
|
// In-app GitHub Issues page. Reached from Settings → About.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct HelpFeedbackView: View {
|
struct HelpFeedbackView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// HistoryView.swift
|
// HistoryView.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct HistoryView: View {
|
struct HistoryView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// then engine / session status at the scroll bottom. History/dictionary
|
// then engine / session status at the scroll bottom. History/dictionary
|
||||||
// open via push (system back) rather than bottom-tab destinations.
|
// open via push (system back) rather than bottom-tab destinations.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum HomeRoute: Hashable {
|
private enum HomeRoute: Hashable {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
// Local engine: partial transcripts stream into the text box live.
|
// Local engine: partial transcripts stream into the text box live.
|
||||||
// Cloud engine: final transcript is appended when recording stops.
|
// Cloud engine: final transcript is appended when recording stops.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct KeyboardPreviewSheet: View {
|
struct KeyboardPreviewSheet: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
|
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
|
||||||
// when the user enables the keyboard in iOS Settings.
|
// when the user enables the keyboard in iOS Settings.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct KeyboardPreviewStub: View {
|
struct KeyboardPreviewStub: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// `DictationTranscriber` (nothing to download).
|
// `DictationTranscriber` (nothing to download).
|
||||||
// - Post-ASR polish uses the LLM provider / API key configured in Settings.
|
// - Post-ASR polish uses the LLM provider / API key configured in Settings.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Local models group
|
// MARK: - Local models group
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// Only constructed when `AppGroup.isAvailable` so the error path never
|
// Only constructed when `AppGroup.isAvailable` so the error path never
|
||||||
// touches App Group–backed singletons.
|
// touches App Group–backed singletons.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct MainAppRoot: View {
|
struct MainAppRoot: View {
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@@ -17,15 +17,24 @@ struct MainAppRoot: View {
|
|||||||
@ObservedObject private var config = ProviderConfig.shared
|
@ObservedObject private var config = ProviderConfig.shared
|
||||||
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
||||||
@StateObject private var flowManager = FlowSessionManager()
|
@StateObject private var flowManager = FlowSessionManager()
|
||||||
|
@StateObject private var accountSession: AccountSessionCoordinator
|
||||||
@State private var clmWarmupTask: Task<Void, Never>?
|
@State private var clmWarmupTask: Task<Void, Never>?
|
||||||
@State private var rimeStartupTask: Task<Void, Never>?
|
@State private var rimeStartupTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(accountDependencies: AccountDependencies? = nil) {
|
||||||
|
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
||||||
|
_accountSession = StateObject(
|
||||||
|
wrappedValue: AccountSessionCoordinator(dependencies: resolvedDependencies)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
mainContent
|
mainContent
|
||||||
}
|
}
|
||||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||||
.environmentObject(flowManager)
|
.environmentObject(flowManager)
|
||||||
|
.environmentObject(accountSession)
|
||||||
.background {
|
.background {
|
||||||
FlowPiPHostView { view in
|
FlowPiPHostView { view in
|
||||||
flowManager.attachPiPHostView(view)
|
flowManager.attachPiPHostView(view)
|
||||||
@@ -88,6 +97,10 @@ struct MainAppRoot: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
await accountSession.restoreIfNeeded()
|
||||||
|
config.reloadFromPersistedStorage()
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||||
config.reloadFromPersistedStorage()
|
config.reloadFromPersistedStorage()
|
||||||
}
|
}
|
||||||
@@ -102,8 +115,20 @@ struct MainAppRoot: View {
|
|||||||
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
|
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
|
||||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||||
}
|
}
|
||||||
|
if accountSession.shouldPresentAccountCenter {
|
||||||
|
Task { @MainActor in
|
||||||
|
// Let MainTabView enter the hierarchy before publishing.
|
||||||
|
await Task.yield()
|
||||||
|
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: config.engineMode) { _, _ in
|
||||||
|
guard config.credentialSource == .managed,
|
||||||
|
accountSession.isSignedIn else { return }
|
||||||
|
Task { _ = await accountSession.prepareManagedGateway() }
|
||||||
|
}
|
||||||
.onChange(of: scenePhase) { _, phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
flowManager.handleScenePhase(phase)
|
flowManager.handleScenePhase(phase)
|
||||||
guard phase == .active else {
|
guard phase == .active else {
|
||||||
@@ -137,8 +162,8 @@ struct MainAppRoot: View {
|
|||||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rime remains startup-owned, but a short delay keeps its CPU and file I/O
|
/// Rime remains startup-owned, but PiP gets exclusive use of the launch
|
||||||
/// away from SwiftUI's first-frame layout on installs and version updates.
|
/// critical path before deployment claims CPU, file I/O and memory.
|
||||||
private func scheduleRimeDeployment(reason: String) {
|
private func scheduleRimeDeployment(reason: String) {
|
||||||
rimeStartupTask?.cancel()
|
rimeStartupTask?.cancel()
|
||||||
guard !RimeResourceInstaller.isReady else {
|
guard !RimeResourceInstaller.isReady else {
|
||||||
@@ -148,12 +173,22 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"rime startup scheduled reason=\(reason) delay=500ms \(OSGDiag.memoryTag())",
|
"rime startup scheduled reason=\(reason) afterFlowStart \(OSGDiag.memoryTag())",
|
||||||
category: "flow"
|
category: "flow"
|
||||||
)
|
)
|
||||||
rimeStartupTask = Task { @MainActor in
|
rimeStartupTask = Task { @MainActor in
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
guard !Task.isCancelled, scenePhase == .active else { return }
|
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||||
|
|
||||||
|
// A normal cold PiP start settles in under one second. Keep a
|
||||||
|
// bounded ceiling so an unavailable PiP never blocks typing
|
||||||
|
// resource installation for the rest of the foreground session.
|
||||||
|
let flowDeadline = Date().addingTimeInterval(12)
|
||||||
|
while flowManager.shouldDeferHostHeavyWork, Date() < flowDeadline {
|
||||||
|
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||||
RimeDeploymentController.shared.deployNow(reason: reason)
|
RimeDeploymentController.shared.deployNow(reason: reason)
|
||||||
rimeStartupTask = nil
|
rimeStartupTask = nil
|
||||||
}
|
}
|
||||||
@@ -210,6 +245,11 @@ struct MainAppRoot: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleIncomingURL(_ url: URL) {
|
private func handleIncomingURL(_ url: URL) {
|
||||||
|
if accountSession.handleIncomingURL(url) {
|
||||||
|
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
guard url.scheme == "osgkeyboard" else { return }
|
guard url.scheme == "osgkeyboard" else { return }
|
||||||
switch url.host {
|
switch url.host {
|
||||||
case "startflow":
|
case "startflow":
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
|
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
|
||||||
// and Flow session behaviour unchanged underneath.
|
// and Flow session behaviour unchanged underneath.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
struct MainSplitView: View {
|
struct MainSplitView: View {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Shared tab destination switcher used by both the phone dock and the iPad
|
// Shared tab destination switcher used by both the phone dock and the iPad
|
||||||
// split-view detail column.
|
// split-view detail column.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct MainTabContent: View {
|
struct MainTabContent: View {
|
||||||
let tab: AppTab
|
let tab: AppTab
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// MainTabView.swift
|
// MainTabView.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@@ -29,6 +29,9 @@ struct MainTabView: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||||
tab = .settings
|
tab = .settings
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||||
|
tab = .settings
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Phone layout
|
// MARK: - Phone layout
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// Launch with `--whats-new-host` (+ optional `--whats-new-scenario=` / `--whats-new-lang=`).
|
// Launch with `--whats-new-host` (+ optional `--whats-new-scenario=` / `--whats-new-lang=`).
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct NotesHostDemoView: View {
|
struct NotesHostDemoView: View {
|
||||||
let scenario: WhatsNewDemoScenario
|
let scenario: WhatsNewDemoScenario
|
||||||
@@ -20,8 +20,8 @@ struct NotesHostDemoView: View {
|
|||||||
switch (scenario, language) {
|
switch (scenario, language) {
|
||||||
case (.ai, .en): return "Messages"
|
case (.ai, .en): return "Messages"
|
||||||
case (.ai, .zh): return "信息"
|
case (.ai, .zh): return "信息"
|
||||||
case (.edit, .en), (.clipboard, .en): return "Notes"
|
case (.edit, .en), (.clipboard, .en), (.clipboardSkills, .en): return "Notes"
|
||||||
case (.edit, .zh), (.clipboard, .zh): return "备忘录"
|
case (.edit, .zh), (.clipboard, .zh), (.clipboardSkills, .zh): return "备忘录"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
// 5) Engine / ASR setup
|
// 5) Engine / ASR setup
|
||||||
// 6) Polish LLM setup
|
// 6) Polish LLM setup
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum OnboardingPage: Int, CaseIterable {
|
private enum OnboardingPage: Int, CaseIterable {
|
||||||
@@ -235,10 +235,14 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
private var pageDots: some View {
|
private var pageDots: some View {
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
ForEach(0..<OnboardingPage.count, id: \.self) { i in
|
ForEach(0..<OnboardingPage.count, id: \.self) { pageIndex in
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(i == config.onboardingPage ? palette.accent : palette.textTertiary.opacity(0.28))
|
.fill(
|
||||||
.frame(width: i == config.onboardingPage ? 18 : 6, height: 6)
|
pageIndex == config.onboardingPage
|
||||||
|
? palette.accent
|
||||||
|
: palette.textTertiary.opacity(0.28)
|
||||||
|
)
|
||||||
|
.frame(width: pageIndex == config.onboardingPage ? 18 : 6, height: 6)
|
||||||
.animation(Motion.quick, value: config.onboardingPage)
|
.animation(Motion.quick, value: config.onboardingPage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -347,8 +351,8 @@ private struct OnboardingTitleBlock: View {
|
|||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
var subtitle: LocalizedStringKey? = nil
|
var subtitle: LocalizedStringKey?
|
||||||
var secondarySubtitle: LocalizedStringKey? = nil
|
var secondarySubtitle: LocalizedStringKey?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: Spacing.xs) {
|
VStack(spacing: Spacing.xs) {
|
||||||
@@ -557,15 +561,15 @@ private struct PermissionPageLayout: View {
|
|||||||
let icon: String
|
let icon: String
|
||||||
let title: LocalizedStringKey
|
let title: LocalizedStringKey
|
||||||
let detail: LocalizedStringKey
|
let detail: LocalizedStringKey
|
||||||
var preface: LocalizedStringKey? = nil
|
var preface: LocalizedStringKey?
|
||||||
let status: LocalizedStringKey
|
let status: LocalizedStringKey
|
||||||
let statusColor: Color
|
let statusColor: Color
|
||||||
let primaryTitle: LocalizedStringKey
|
let primaryTitle: LocalizedStringKey
|
||||||
let primaryDisabled: Bool
|
let primaryDisabled: Bool
|
||||||
let onPrimary: () -> Void
|
let onPrimary: () -> Void
|
||||||
var secondaryTitle: LocalizedStringKey? = nil
|
var secondaryTitle: LocalizedStringKey?
|
||||||
var onSecondary: (() -> Void)? = nil
|
var onSecondary: (() -> Void)?
|
||||||
var deniedHint: LocalizedStringKey? = nil
|
var deniedHint: LocalizedStringKey?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -783,16 +787,24 @@ private struct APISetupPage: View {
|
|||||||
EnginePickerSection(config: config)
|
EnginePickerSection(config: config)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
|
|
||||||
if config.engineMode == "cloud" {
|
if config.credentialSource == .byok {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
AppleASRPickerRow(config: config)
|
||||||
Divider().background(palette.divider)
|
if config.engineMode == "cloud" {
|
||||||
ASRSettingsCard(config: config, showsSurface: false)
|
Divider().background(palette.divider)
|
||||||
|
ProviderPickerSection(
|
||||||
|
config: config,
|
||||||
|
role: .asr,
|
||||||
|
showsSurface: false
|
||||||
|
)
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
ASRSettingsCard(config: config, showsSurface: false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
} else {
|
} else {
|
||||||
Text("onboarding.api.localModels.hint")
|
Text("settings.aiService.credits.subtitle")
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -823,7 +835,13 @@ private struct PolishSetupPage: View {
|
|||||||
)
|
)
|
||||||
.padding(.horizontal, Spacing.lg)
|
.padding(.horizontal, Spacing.lg)
|
||||||
|
|
||||||
if config.isLocalEngine {
|
if config.credentialSource == .managed {
|
||||||
|
Text("settings.aiService.credits.subtitle")
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.padding(.horizontal, Spacing.xl)
|
||||||
|
} else if config.isLocalEngine {
|
||||||
Text("onboarding.polish.localHint")
|
Text("onboarding.polish.localHint")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
@@ -831,13 +849,15 @@ private struct PolishSetupPage: View {
|
|||||||
.padding(.horizontal, Spacing.xl)
|
.padding(.horizontal, Spacing.xl)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
if config.credentialSource == .byok {
|
||||||
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
VStack(spacing: 0) {
|
||||||
Divider().background(palette.divider)
|
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
||||||
APISettingsCard(config: config, showsSurface: false)
|
Divider().background(palette.divider)
|
||||||
|
APISettingsCard(config: config, showsSurface: false)
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
.padding(.horizontal, Spacing.lg)
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
|
||||||
.padding(.horizontal, Spacing.lg)
|
|
||||||
}
|
}
|
||||||
.padding(.bottom, Spacing.xxxl)
|
.padding(.bottom, Spacing.xxxl)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// detail screen with the verbatim license text. Reached from
|
// detail screen with the verbatim license text. Reached from
|
||||||
// Settings → About → "Third-Party Licenses".
|
// Settings → About → "Third-Party Licenses".
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct OpenSourceLicensesView: View {
|
struct OpenSourceLicensesView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Minimal add / edit sheet for a single personal-dictionary term.
|
// Minimal add / edit sheet for a single personal-dictionary term.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct PersonalDictionaryEntrySheet: View {
|
struct PersonalDictionaryEntrySheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// App-Group-shared `PersonalDictionary` so changes are visible to
|
// App-Group-shared `PersonalDictionary` so changes are visible to
|
||||||
// the keyboard extension on the next LLM call.
|
// the keyboard extension on the next LLM call.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PersonalDictionaryView: View {
|
struct PersonalDictionaryView: View {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Main-app editor for complete polish writing personalities. The keyboard
|
// Main-app editor for complete polish writing personalities. The keyboard
|
||||||
// reads the selected pack from App Group storage on the next polish request.
|
// reads the selected pack from App Group storage on the next polish request.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct PolishStylesView: View {
|
struct PolishStylesView: View {
|
||||||
@@ -23,7 +23,7 @@ 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: Spacing.sm),
|
||||||
GridItem(.flexible(), spacing: Spacing.sm),
|
GridItem(.flexible(), spacing: Spacing.sm)
|
||||||
]
|
]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Bundled privacy policy HTML. Reached from Settings → About.
|
// Bundled privacy policy HTML. Reached from Settings → About.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct PrivacyPolicyView: View {
|
struct PrivacyPolicyView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// ProviderPickerSection.swift
|
// ProviderPickerSection.swift
|
||||||
// OSGKeyboard · Main App
|
// OSGKeyboard · Main App
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ProviderPickerSection: View {
|
struct ProviderPickerSection: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Sheet that loads the remote release-notes HTML for the current version.
|
// Sheet that loads the remote release-notes HTML for the current version.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct ReleaseNotesSheet: View {
|
struct ReleaseNotesSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Settings-row toggle for mirroring user preferences through iCloud KVS.
|
// Settings-row toggle for mirroring user preferences through iCloud KVS.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
struct SettingsICloudSyncRow: View {
|
struct SettingsICloudSyncRow: View {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// Shared preference picker / toggle rows used by Settings home and
|
// Shared preference picker / toggle rows used by Settings home and
|
||||||
// secondary pages (General, Voice session, Daily).
|
// secondary pages (General, Voice session, Daily).
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Speech
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import Speech
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - App language picker row
|
// MARK: - App language picker row
|
||||||
|
|
||||||
@@ -232,23 +232,6 @@ struct RememberLastSurfaceToggleRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cursor drag navigation toggle
|
|
||||||
|
|
||||||
struct CursorDragNavigationToggleRow: View {
|
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
|
||||||
@Binding var isOn: Bool
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
Toggle(isOn: $isOn) {
|
|
||||||
Text("settings.cursorDragNavigation.title")
|
|
||||||
.font(TypeStyle.body)
|
|
||||||
.foregroundStyle(palette.textPrimary)
|
|
||||||
}
|
|
||||||
.tint(palette.accent)
|
|
||||||
.settingsListRow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Menu picker row (generic)
|
// MARK: - Menu picker row (generic)
|
||||||
|
|
||||||
struct SettingsMenuPickerRow: View {
|
struct SettingsMenuPickerRow: View {
|
||||||
@@ -366,7 +349,7 @@ enum SettingsASRLocales {
|
|||||||
("zh-Hant", false),
|
("zh-Hant", false),
|
||||||
("en-US", false),
|
("en-US", false),
|
||||||
("ja-JP", false),
|
("ja-JP", false),
|
||||||
("ko-KR", false),
|
("ko-KR", false)
|
||||||
]
|
]
|
||||||
|
|
||||||
static func loadDynamic() async -> [(id: String, onDevice: Bool)] {
|
static func loadDynamic() async -> [(id: String, onDevice: Bool)] {
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
|
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
|
||||||
// stacks label above control; regular-width iPad keeps label/control in one row.
|
// stacks label above control; regular-width iPad keeps label/control in one row.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsProviderRow<Content: View>: View {
|
struct SettingsProviderRow<Content: View>: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// session, general preferences, and about. Main Settings stays a
|
// session, general preferences, and about. Main Settings stays a
|
||||||
// daily console with summary navigation rows.
|
// daily console with summary navigation rows.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
// MARK: - Navigation row (title + optional trailing summary before chevron)
|
// MARK: - Navigation row (title + optional trailing summary before chevron)
|
||||||
|
|
||||||
@@ -140,6 +140,45 @@ private struct CloudProviderSettingsCard<Content: View>: View {
|
|||||||
|
|
||||||
// MARK: - Speech recognition (ASR / local engine)
|
// MARK: - Speech recognition (ASR / local engine)
|
||||||
|
|
||||||
|
struct AppleASRPickerRow: View {
|
||||||
|
@Environment(\.themePalette) private var palette
|
||||||
|
@ObservedObject var config: ProviderConfig
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: usesAppleRecognition) {
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
Image(systemName: "apple.logo")
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text("settings.asr.apple.title")
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("settings.asr.apple.subtitle")
|
||||||
|
.font(TypeStyle.caption2)
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tint(palette.accent)
|
||||||
|
.settingsListRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var usesAppleRecognition: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { config.engineMode == "local" },
|
||||||
|
set: { isEnabled in
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
config.engineMode = isEnabled ? "local" : "cloud"
|
||||||
|
if !isEnabled {
|
||||||
|
config.modeId = "polish"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct SpeechRecognitionSettingsView: View {
|
struct SpeechRecognitionSettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
@@ -147,15 +186,24 @@ struct SpeechRecognitionSettingsView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent {
|
CardPageContent {
|
||||||
if config.engineMode == "cloud" {
|
CardSection("settings.asrProvider.title") {
|
||||||
CardSection("settings.asrProvider.title") {
|
CloudProviderSettingsCard {
|
||||||
CloudProviderSettingsCard {
|
AppleASRPickerRow(config: config)
|
||||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
|
||||||
|
if config.engineMode == "cloud" {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
ProviderPickerSection(
|
||||||
|
config: config,
|
||||||
|
role: .asr,
|
||||||
|
showsSurface: false
|
||||||
|
)
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
ASRSettingsCard(config: config, showsSurface: false)
|
ASRSettingsCard(config: config, showsSurface: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
if config.engineMode == "local" {
|
||||||
CardSection("settings.localEngine.title") {
|
CardSection("settings.localEngine.title") {
|
||||||
LocalModelsGroup(config: config)
|
LocalModelsGroup(config: config)
|
||||||
}
|
}
|
||||||
@@ -259,10 +307,6 @@ struct GeneralSettingsView: View {
|
|||||||
set: { config.keyboardHapticIntensity = $0 }
|
set: { config.keyboardHapticIntensity = $0 }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
Divider().background(palette.divider)
|
|
||||||
CursorDragNavigationToggleRow(
|
|
||||||
isOn: $config.cursorDragNavigationEnabled
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
@@ -310,9 +354,13 @@ struct AIAgentSettingsView: View {
|
|||||||
|
|
||||||
struct ClipboardSettingsView: View {
|
struct ClipboardSettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
@ObservedObject var config: ProviderConfig
|
@ObservedObject var config: ProviderConfig
|
||||||
@ObservedObject private var history = ClipboardHistoryStore.shared
|
@ObservedObject private var history = ClipboardHistoryStore.shared
|
||||||
@State private var showClearConfirmation = false
|
@State private var showClearConfirmation = false
|
||||||
|
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
@State private var pasteAccessNeedsRecovery = false
|
||||||
|
@State private var showPasteNoTextAlert = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -356,40 +404,58 @@ struct ClipboardSettingsView: View {
|
|||||||
// "Paste from Other Apps" permission to Allow.
|
// "Paste from Other Apps" permission to Allow.
|
||||||
CardSection("settings.clipboard.paste.section") {
|
CardSection("settings.clipboard.paste.section") {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Text("settings.clipboard.paste.body")
|
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||||
.font(.footnote)
|
Text("settings.clipboard.paste.body")
|
||||||
.foregroundStyle(palette.textSecondary)
|
.font(.footnote)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.padding(.horizontal, 16)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, 12)
|
if pasteAccessVerified {
|
||||||
|
Label(
|
||||||
Divider().background(palette.divider)
|
"settings.clipboard.paste.verified",
|
||||||
|
systemImage: "checkmark.circle.fill"
|
||||||
Button {
|
|
||||||
AppPermissions.requestPasteAccess()
|
|
||||||
} label: {
|
|
||||||
SettingsNavigationRow(
|
|
||||||
titleText: AppL10n.string(
|
|
||||||
"settings.clipboard.paste.request",
|
|
||||||
language: config.uiLanguage
|
|
||||||
)
|
)
|
||||||
)
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.accent)
|
||||||
|
.fixedSize()
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.verified")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
|
||||||
Divider().background(palette.divider)
|
if !pasteAccessVerified {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
AppPermissions.openSystemSettings()
|
verifyPasteAccess()
|
||||||
} label: {
|
} label: {
|
||||||
SettingsNavigationRow(
|
SettingsNavigationRow(
|
||||||
titleText: AppL10n.string(
|
titleText: AppL10n.string(
|
||||||
"settings.clipboard.paste.open",
|
"settings.clipboard.paste.request",
|
||||||
language: config.uiLanguage
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.verify")
|
||||||
|
|
||||||
|
if pasteAccessNeedsRecovery {
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
AppPermissions.openSystemSettings()
|
||||||
|
} label: {
|
||||||
|
SettingsNavigationRow(
|
||||||
|
titleText: AppL10n.string(
|
||||||
|
"settings.clipboard.paste.open",
|
||||||
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("settings.clipboard.paste.openSettings")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
}
|
||||||
.surfaceCard()
|
.surfaceCard()
|
||||||
}
|
}
|
||||||
@@ -441,8 +507,22 @@ struct ClipboardSettingsView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("settings.clipboard.clear.message")
|
Text("settings.clipboard.clear.message")
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||||
|
isPresented: $showPasteNoTextAlert
|
||||||
|
) {
|
||||||
|
Button("common.done") { showPasteNoTextAlert = false }
|
||||||
|
} message: {
|
||||||
|
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
history.reload()
|
history.reload()
|
||||||
|
refreshPasteAccessState()
|
||||||
|
}
|
||||||
|
.onChange(of: scenePhase) { _, phase in
|
||||||
|
guard phase == .active else { return }
|
||||||
|
refreshPasteAccessState()
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
}
|
}
|
||||||
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
@@ -457,6 +537,27 @@ struct ClipboardSettingsView: View {
|
|||||||
set: { config.clipboardCandidateBarEnabled = $0 }
|
set: { config.clipboardCandidateBarEnabled = $0 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func verifyPasteAccess() {
|
||||||
|
switch AppPermissions.requestPasteAccess() {
|
||||||
|
case .verified:
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
pasteAccessVerified = true
|
||||||
|
pasteAccessNeedsRecovery = false
|
||||||
|
}
|
||||||
|
case .noTextAvailable:
|
||||||
|
showPasteNoTextAlert = true
|
||||||
|
case .unavailable:
|
||||||
|
withAnimation(Motion.quick) {
|
||||||
|
pasteAccessVerified = false
|
||||||
|
pasteAccessNeedsRecovery = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshPasteAccessState() {
|
||||||
|
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - About
|
// MARK: - About
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// Settings home: daily controls + summary navigation into secondary
|
// Settings home: daily controls + summary navigation into secondary
|
||||||
// pages for low-frequency configuration.
|
// pages for low-frequency configuration.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
|
||||||
import OSGKeyboardHostSupport
|
import OSGKeyboardHostSupport
|
||||||
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum SettingsPresentation {
|
enum SettingsPresentation {
|
||||||
case tab
|
case tab
|
||||||
@@ -18,6 +18,7 @@ enum SettingsPresentation {
|
|||||||
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
|
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
|
||||||
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||||
private enum SettingsRoute: Hashable {
|
private enum SettingsRoute: Hashable {
|
||||||
|
case account
|
||||||
case speechRecognition
|
case speechRecognition
|
||||||
case textPolish
|
case textPolish
|
||||||
case general
|
case general
|
||||||
@@ -28,6 +29,7 @@ private enum SettingsRoute: Hashable {
|
|||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||||
|
|
||||||
@ObservedObject var config = ProviderConfig.shared
|
@ObservedObject var config = ProviderConfig.shared
|
||||||
|
|
||||||
@@ -48,9 +50,7 @@ struct SettingsView: View {
|
|||||||
palette.background.ignoresSafeArea()
|
palette.background.ignoresSafeArea()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
CardPageContent {
|
CardPageContent {
|
||||||
if presentation == .tab {
|
accountEntrySection
|
||||||
SupportDeveloperSection(language: config.uiLanguage)
|
|
||||||
}
|
|
||||||
dailySection
|
dailySection
|
||||||
transcriptionAndPolishSection
|
transcriptionAndPolishSection
|
||||||
if presentation == .tab {
|
if presentation == .tab {
|
||||||
@@ -97,10 +97,17 @@ struct SettingsView: View {
|
|||||||
settingsDestination(for: route)
|
settingsDestination(for: route)
|
||||||
}
|
}
|
||||||
.task { await loadDynamicLocales() }
|
.task { await loadDynamicLocales() }
|
||||||
.onAppear { consumeClipboardDeepLinkIfNeeded() }
|
.onAppear {
|
||||||
|
consumeClipboardDeepLinkIfNeeded()
|
||||||
|
consumeAccountDeepLinkIfNeeded()
|
||||||
|
Task { await accountSession.refreshAccountData() }
|
||||||
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||||
consumeClipboardDeepLinkIfNeeded()
|
consumeClipboardDeepLinkIfNeeded()
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||||
|
consumeAccountDeepLinkIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +119,19 @@ struct SettingsView: View {
|
|||||||
path.append(SettingsRoute.clipboard)
|
path.append(SettingsRoute.clipboard)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func consumeAccountDeepLinkIfNeeded() {
|
||||||
|
guard accountSession.consumeAccountCenterPresentation() else { return }
|
||||||
|
if !path.isEmpty {
|
||||||
|
path = NavigationPath()
|
||||||
|
}
|
||||||
|
path.append(SettingsRoute.account)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func settingsDestination(for route: SettingsRoute) -> some View {
|
private func settingsDestination(for route: SettingsRoute) -> some View {
|
||||||
switch route {
|
switch route {
|
||||||
|
case .account:
|
||||||
|
AccountCenterView()
|
||||||
case .speechRecognition:
|
case .speechRecognition:
|
||||||
SpeechRecognitionSettingsView(config: config)
|
SpeechRecognitionSettingsView(config: config)
|
||||||
case .textPolish:
|
case .textPolish:
|
||||||
@@ -130,6 +147,236 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Optional account
|
||||||
|
|
||||||
|
private var accountEntrySection: some View {
|
||||||
|
CardSection("account.settings.section") {
|
||||||
|
VStack(spacing: Spacing.sm) {
|
||||||
|
Group {
|
||||||
|
switch accountSession.sessionPhase {
|
||||||
|
case .restoring:
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
ProgressView()
|
||||||
|
.tint(palette.accent)
|
||||||
|
Text("account.settings.restoring")
|
||||||
|
.font(TypeStyle.body)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.settingsListRow()
|
||||||
|
case .signedOut:
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text("account.signedOut.title")
|
||||||
|
.font(TypeStyle.headline)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text("account.signedOut.body")
|
||||||
|
.font(TypeStyle.footnote)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||||
|
.disabled(accountSession.operation != nil)
|
||||||
|
}
|
||||||
|
.padding(Spacing.md)
|
||||||
|
case let .signedIn(session):
|
||||||
|
Button {
|
||||||
|
path.append(SettingsRoute.account)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: Spacing.md) {
|
||||||
|
AccountAvatarView(
|
||||||
|
accountID: session.accountID,
|
||||||
|
displayName: session.displayName,
|
||||||
|
size: 44
|
||||||
|
)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||||
|
Text(
|
||||||
|
session.displayName
|
||||||
|
?? AppL10n.string(
|
||||||
|
"account.profile.fallbackName",
|
||||||
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(TypeStyle.bodyEmph)
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
Text(accountSettingsSubtitle)
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(palette.textTertiary)
|
||||||
|
}
|
||||||
|
.settingsListRow()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.accessibilityLabel(
|
||||||
|
Text(
|
||||||
|
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
|
||||||
|
if accountSession.isSignedIn {
|
||||||
|
accountRewardsEntrySection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accountRewardsEntrySection: some View {
|
||||||
|
Group {
|
||||||
|
switch accountSession.snapshotPhase {
|
||||||
|
case let .loaded(snapshot):
|
||||||
|
let totalCredits = creditTotal(snapshot)
|
||||||
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||||
|
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)
|
||||||
|
|
||||||
|
Divider().background(palette.divider)
|
||||||
|
invitationLink(snapshot.referralProfile)
|
||||||
|
}
|
||||||
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.surfaceCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func invitationLink(_ profile: AccountReferralProfile) -> some View {
|
||||||
|
if let code = profile.code,
|
||||||
|
let invitationURL = ReferralUniversalLink.invitationURL(for: code) {
|
||||||
|
HStack(spacing: Spacing.md) {
|
||||||
|
Text(
|
||||||
|
AppL10n.string(
|
||||||
|
"account.referral.equalRewardDescription",
|
||||||
|
language: config.uiLanguage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
Spacer(minLength: Spacing.xs)
|
||||||
|
AccountInvitationButton(invitationURL: invitationURL)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HStack(spacing: Spacing.sm) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
.tint(palette.accent)
|
||||||
|
Text("account.referral.preparingLink")
|
||||||
|
.font(TypeStyle.footnote)
|
||||||
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
switch accountSession.sessionPhase {
|
||||||
|
case .restoring:
|
||||||
|
return AppL10n.string("account.settings.restoring", language: config.uiLanguage)
|
||||||
|
case .signedOut:
|
||||||
|
return AppL10n.string("account.settings.signedOut", language: config.uiLanguage)
|
||||||
|
case .signedIn:
|
||||||
|
return AppL10n.string("account.settings.signedIn", language: config.uiLanguage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Daily (high-frequency)
|
// MARK: - Daily (high-frequency)
|
||||||
|
|
||||||
private var dailySection: some View {
|
private var dailySection: some View {
|
||||||
|
|||||||
@@ -17,8 +17,8 @@
|
|||||||
// The pipeline (`PolishingService`) honors `.translate` on both
|
// The pipeline (`PolishingService`) honors `.translate` on both
|
||||||
// engines when this row is visible — no more "rejected mode" toast.
|
// engines when this row is visible — no more "rejected mode" toast.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct TranslationPickerRow: View {
|
struct TranslationPickerRow: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Schema and opt-in fuzzy-pinyin settings. Changing fuzzy pairs triggers
|
// Schema and opt-in fuzzy-pinyin settings. Changing fuzzy pairs triggers
|
||||||
// host-side redeployment; the keyboard extension never compiles schemas.
|
// host-side redeployment; the keyboard extension never compiles schemas.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct TypingInputSettingsView: View {
|
struct TypingInputSettingsView: View {
|
||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|||||||
@@ -104,6 +104,17 @@
|
|||||||
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared. Clipboard history is kept.";
|
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared. Clipboard history is kept.";
|
||||||
"settings.reset.confirm" = "Reset all settings";
|
"settings.reset.confirm" = "Reset all settings";
|
||||||
"settings.engine.title" = "Speech Transcription & Polish";
|
"settings.engine.title" = "Speech Transcription & Polish";
|
||||||
|
"settings.aiService.title" = "AI Service";
|
||||||
|
"settings.aiService.credits.title" = "Use Credits";
|
||||||
|
"settings.aiService.credits.subtitle" = "OSG handles transcription and polishing with your credit balance.";
|
||||||
|
"settings.aiService.credits.signIn" = "Sign in above to use OSG credits.";
|
||||||
|
"settings.aiService.credits.consent.title" = "Use OSG Managed Cloud Services?";
|
||||||
|
"settings.aiService.credits.consent.message" = "When you use credits, voice recordings are sent through OSG's managed gateway to the managed speech provider. Transcripts, nearby cursor context, and AI questions may also be sent through the gateway for polishing or answers. Requests are linked to your OSG account for authorization and credit billing. Local recognition and your own API keys are unaffected.";
|
||||||
|
"settings.aiService.credits.consent.accept" = "Agree & Use Credits";
|
||||||
|
"settings.aiService.byok.title" = "I Have API Keys";
|
||||||
|
"settings.aiService.byok.subtitle" = "Use Apple on-device transcription or your own cloud providers.";
|
||||||
|
"settings.asr.apple.title" = "Apple On-Device Recognition";
|
||||||
|
"settings.asr.apple.subtitle" = "Private transcription on this device; no ASR key required.";
|
||||||
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
|
"settings.engine.subtitle" = "Local: built-in system transcription. Cloud: ASR transcription with optional LLM polish.";
|
||||||
"settings.engine.local.title" = "On-device transcription";
|
"settings.engine.local.title" = "On-device transcription";
|
||||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||||
@@ -205,20 +216,23 @@
|
|||||||
"settings.clipboard.subtitle.on" = "On";
|
"settings.clipboard.subtitle.on" = "On";
|
||||||
"settings.clipboard.subtitle.off" = "Off";
|
"settings.clipboard.subtitle.off" = "Off";
|
||||||
"settings.clipboard.history.title" = "History";
|
"settings.clipboard.history.title" = "History";
|
||||||
"settings.clipboard.history.footer" = "Off by default. Captures text copied on this device or through Universal Clipboard and keeps up to 15 items in this device’s App Group. AI mode can suggest clipboard-related prompts for about 30 seconds after a copy.";
|
"settings.clipboard.history.footer" = "Keeps up to 15 copied text items on this device. Off by default.";
|
||||||
"settings.clipboard.candidate.title" = "Suggestion strip";
|
"settings.clipboard.candidate.title" = "Suggestion strip";
|
||||||
"settings.clipboard.candidate.footer" = "Shows the newest copy above the keys for one-tap insert.";
|
"settings.clipboard.candidate.footer" = "Shows your latest copy above the keyboard. Tap to insert.";
|
||||||
"settings.clipboard.paste.section" = "System access";
|
"settings.clipboard.paste.section" = "System access";
|
||||||
"settings.clipboard.paste.body" = "Copy some text, tap Request Paste Access, and allow access. iOS will then create the Paste from Other Apps setting, where you can select Allow.";
|
"settings.clipboard.paste.body" = "Allows clipboard skills to read text you copy.";
|
||||||
"settings.clipboard.paste.request" = "Request Paste Access";
|
"settings.clipboard.paste.request" = "Verify Paste Access";
|
||||||
|
"settings.clipboard.paste.verified" = "Verified";
|
||||||
"settings.clipboard.paste.open" = "Open iOS Settings";
|
"settings.clipboard.paste.open" = "Open iOS Settings";
|
||||||
"settings.clipboard.storage.section" = "Local history";
|
"settings.clipboard.storage.section" = "Local history";
|
||||||
"settings.clipboard.storage.body" = "Turning History off stops capture and the suggestion strip but keeps saved items. Clipboard history does not sync through iCloud and is never sent to AI automatically. After you insert it, active polish may include it as context for your configured provider. Sensitive filtering is conservative and cannot detect every password.";
|
"settings.clipboard.storage.body" = "History stays on this device, does not sync through iCloud, and is never sent to AI automatically. Turning History off keeps saved items.";
|
||||||
|
"clipboard.paste.noText.title" = "Copy text first";
|
||||||
|
"clipboard.paste.noText.message" = "Copy a short piece of text, then try again.";
|
||||||
"settings.clipboard.clear.button" = "Clear clipboard history";
|
"settings.clipboard.clear.button" = "Clear clipboard history";
|
||||||
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
"settings.clipboard.clear.title" = "Clear clipboard history?";
|
||||||
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
"settings.clipboard.clear.message" = "This permanently removes all saved clipboard items from this device. This cannot be undone.";
|
||||||
"settings.clipboard.clear.confirm" = "Clear history";
|
"settings.clipboard.clear.confirm" = "Clear history";
|
||||||
"settings.typingInput.title" = "Text Input";
|
"settings.typingInput.title" = "Pinyin Input";
|
||||||
"settings.typingInput.default.title" = "Default Input";
|
"settings.typingInput.default.title" = "Default Input";
|
||||||
"settings.typingInput.default.description" = "Used when the keyboard opens";
|
"settings.typingInput.default.description" = "Used when the keyboard opens";
|
||||||
"settings.typingInput.default.mode.voice" = "Voice";
|
"settings.typingInput.default.mode.voice" = "Voice";
|
||||||
@@ -251,7 +265,7 @@
|
|||||||
"settings.handedness.title" = "Handedness";
|
"settings.handedness.title" = "Handedness";
|
||||||
"settings.handedness.left" = "Left hand";
|
"settings.handedness.left" = "Left hand";
|
||||||
"settings.handedness.right" = "Right hand";
|
"settings.handedness.right" = "Right hand";
|
||||||
"settings.keyboardHaptic.title" = "Haptics";
|
"settings.keyboardHaptic.title" = "Key Haptics";
|
||||||
"settings.keyboardHaptic.off" = "Off";
|
"settings.keyboardHaptic.off" = "Off";
|
||||||
"settings.keyboardHaptic.light" = "Light";
|
"settings.keyboardHaptic.light" = "Light";
|
||||||
"settings.keyboardHaptic.strong" = "Strong";
|
"settings.keyboardHaptic.strong" = "Strong";
|
||||||
@@ -268,9 +282,9 @@
|
|||||||
"settings.privacy.policy" = "Privacy Policy";
|
"settings.privacy.policy" = "Privacy Policy";
|
||||||
"settings.privacy.fullAccess.title" = "About Full Access";
|
"settings.privacy.fullAccess.title" = "About Full Access";
|
||||||
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
|
"settings.privacy.fullAccess.body" = "Full Access is required for the microphone and to read your API key. OSGKeyboard does not record or upload what you type with the keyboard.";
|
||||||
"settings.privacy.cloud.body" = "In Cloud polish mode, transcribed text is sent to the API endpoint you configure (e.g. OpenAI or your own server). OSGKeyboard does not operate servers and does not store transcripts in the cloud.";
|
"settings.privacy.cloud.body" = "With your own API key, cloud requests go directly to the provider you configure. When you choose OSG credits, audio and text requests go through OSG's managed gateway to the managed provider.";
|
||||||
"settings.privacy.cloud.alert.title" = "Audio leaves your device";
|
"settings.privacy.cloud.alert.title" = "Cloud processing sends data off-device";
|
||||||
"settings.privacy.cloud.alert.message" = "The cloud engine uploads your voice recordings to the third-party ASR provider you configure, and sends the transcript to its API for polish. That provider's privacy policy applies. OSGKeyboard never stores data on our servers. Continue?";
|
"settings.privacy.cloud.alert.message" = "The selected cloud path sends voice recordings and relevant text off-device for transcription, polishing, or AI answers. User-configured requests go directly to that provider; managed-credit requests go through OSG's managed gateway.";
|
||||||
"settings.link.support" = "Help & Feedback";
|
"settings.link.support" = "Help & Feedback";
|
||||||
"settings.link.github" = "GitHub";
|
"settings.link.github" = "GitHub";
|
||||||
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub.";
|
"settings.support.footer" = "OSGKeyboard is open source. Report bugs and share ideas on GitHub.";
|
||||||
@@ -440,9 +454,16 @@
|
|||||||
"skills.enabled.section" = "In use (%d/%d)";
|
"skills.enabled.section" = "In use (%d/%d)";
|
||||||
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
|
||||||
"skills.available.section" = "Available";
|
"skills.available.section" = "Available";
|
||||||
"skills.clipboard.guide.title" = "Clipboard access needed";
|
"skills.clipboard.guide.title" = "Enable clipboard skills";
|
||||||
"skills.clipboard.guide.body" = "Turn on Clipboard History in the app, then allow paste access in iOS Settings, so these skills can run after you copy text.";
|
"skills.clipboard.guide.body" = "Copy text to summarize, translate, or extract actions from it.";
|
||||||
"skills.clipboard.guide.openAppSettings" = "Open Clipboard settings";
|
"skills.clipboard.guide.enableHistory" = "Turn On Clipboard History";
|
||||||
|
"skills.clipboard.guide.verify.title" = "One more step";
|
||||||
|
"skills.clipboard.guide.verify.body" = "Allow OSGKeyboard to read text you copy.";
|
||||||
|
"skills.clipboard.guide.verify.action" = "Verify Paste Access";
|
||||||
|
"skills.clipboard.guide.recovery.title" = "Paste access is off";
|
||||||
|
"skills.clipboard.guide.recovery.body" = "Allow Paste from Other Apps in iOS Settings, then return to verify.";
|
||||||
|
"skills.clipboard.guide.success.title" = "Clipboard skills are ready";
|
||||||
|
"skills.clipboard.guide.success.body" = "Copy text to use them from the keyboard.";
|
||||||
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
"skills.clipboard.guide.openSystemSettings" = "Open iOS Settings";
|
||||||
"skills.detail.title" = "Skill";
|
"skills.detail.title" = "Skill";
|
||||||
"skills.badge.default" = "Default skill";
|
"skills.badge.default" = "Default skill";
|
||||||
@@ -660,3 +681,127 @@
|
|||||||
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
|
"flow.error.editLastInputFailed" = "Could not complete the edit. Please try again.";
|
||||||
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
|
"flow.error.aiQuestionFailed" = "AI response failed. Please try again.";
|
||||||
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
|
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
|
||||||
|
|
||||||
|
/* Optional account center */
|
||||||
|
"account.title" = "Account";
|
||||||
|
"account.settings.section" = "Account";
|
||||||
|
"account.settings.restoring" = "Checking…";
|
||||||
|
"account.settings.signedOut" = "Not signed in";
|
||||||
|
"account.settings.signedIn" = "Signed in";
|
||||||
|
"account.settings.balance %lld" = "%lld credits";
|
||||||
|
"account.loading.session" = "Checking account status…";
|
||||||
|
"account.loading.center" = "Loading account…";
|
||||||
|
"account.signedOut.title" = "Your OSG account";
|
||||||
|
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
|
||||||
|
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
||||||
|
"account.signIn.apple" = "Sign in with Apple";
|
||||||
|
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||||
|
"account.signIn.loading" = "Signing in…";
|
||||||
|
"account.summary.section" = "Account";
|
||||||
|
"account.summary.signedIn" = "Signed in";
|
||||||
|
"account.summary.protected" = "Protected account";
|
||||||
|
"account.profile.section" = "Profile";
|
||||||
|
"account.profile.fallbackName" = "OSG User";
|
||||||
|
"account.profile.nickname" = "Nickname";
|
||||||
|
"account.profile.save" = "Save Nickname";
|
||||||
|
"account.profile.editTitle" = "Edit";
|
||||||
|
"account.profile.editMessage" = "Enter the nickname shown with your account.";
|
||||||
|
"account.error.updateProfile" = "Could not update your nickname.";
|
||||||
|
"account.summary.identifier" = "Account identifier";
|
||||||
|
"account.credits.section" = "Credits";
|
||||||
|
"account.credits.balance" = "Available credits";
|
||||||
|
"account.credits.unit" = "Available credits";
|
||||||
|
"account.credits.remaining" = "Remaining";
|
||||||
|
"account.credits.used" = "Used";
|
||||||
|
"account.credits.total" = "total credits";
|
||||||
|
"account.credits.remainingCompact" = "%@";
|
||||||
|
"account.credits.usedCompact" = "Used %@";
|
||||||
|
"account.credits.totalValueCompact" = "Total %@";
|
||||||
|
"account.credits.updating" = "Updating credits…";
|
||||||
|
"account.credits.updatedAt" = "Updated at %@";
|
||||||
|
"account.credits.updateFailedAt" = "Update failed · Showing data from %@";
|
||||||
|
"account.credits.notUpdated" = "Credits have not been updated";
|
||||||
|
"account.storekit.section" = "Add credits";
|
||||||
|
"account.storekit.credits" = "credits";
|
||||||
|
"account.storekit.purchase" = "Purchase";
|
||||||
|
"account.storekit.loading" = "Loading App Store product…";
|
||||||
|
"account.storekit.pending" = "Purchase pending approval.";
|
||||||
|
"account.storekit.succeeded" = "%lld credits added";
|
||||||
|
"account.storekit.noRestore" = "Credit packs are consumable and cannot be restored.";
|
||||||
|
"account.storekit.error.productUnavailable" = "This credit pack is not available from the App Store.";
|
||||||
|
"account.storekit.error.verification" = "The App Store could not verify this transaction.";
|
||||||
|
"account.storekit.error.server" = "The account server could not record this purchase.";
|
||||||
|
"account.storekit.error.session" = "Sign in again before purchasing credits.";
|
||||||
|
"account.storekit.error.network" = "Connect to the internet and try again.";
|
||||||
|
"account.storekit.error.conflict" = "This transaction cannot be applied to the current account.";
|
||||||
|
"account.storekit.error.unknown" = "The purchase could not be completed.";
|
||||||
|
"account.purchaseHistory.title" = "Purchase History";
|
||||||
|
"account.purchaseHistory.loading" = "Loading purchase history…";
|
||||||
|
"account.purchaseHistory.empty" = "No purchases yet";
|
||||||
|
"account.purchaseHistory.error" = "Purchase history could not be loaded. Try again.";
|
||||||
|
"account.purchaseHistory.loadMoreError" = "Could not load more purchases. Tap to retry";
|
||||||
|
"account.purchaseHistory.balanceAfter" = "Balance after %@";
|
||||||
|
"account.purchaseHistory.status.credited" = "Credited";
|
||||||
|
"account.ledger.section" = "Credit history";
|
||||||
|
"account.ledger.empty" = "No credit activity yet";
|
||||||
|
"account.ledger.defaultReason" = "Credit activity";
|
||||||
|
"account.rates.section" = "Rates";
|
||||||
|
"account.rates.empty" = "No managed-service rates available";
|
||||||
|
"account.rate.asr.seconds" = "%lld credit / %lld seconds";
|
||||||
|
"account.rate.asr.milliseconds" = "%lld credit / %lld ms";
|
||||||
|
"account.rate.llm" = "Input: %lld credit / %lld tokens · Output: %lld credit / %lld tokens";
|
||||||
|
"account.referral.section" = "Invitations";
|
||||||
|
"account.referral.yourCode" = "Your invitation code";
|
||||||
|
"account.referral.notCreated" = "Not created";
|
||||||
|
"account.rewards.title" = "Credits & Invitations";
|
||||||
|
"account.rewards.details" = "View credits and invitation details";
|
||||||
|
"account.referral.share" = "Share invitation";
|
||||||
|
"account.referral.inviteAction" = "Invite friends and earn credits";
|
||||||
|
"account.referral.rewardExplanation" = "After a friend registers through your link and completes qualifying usage, both of you can earn credits.";
|
||||||
|
"account.referral.rewardGeneric" = "Invite a friend and earn credits";
|
||||||
|
"account.referral.rewardPerFriend" = "Earn %@ credits for each successful invitation";
|
||||||
|
"account.referral.qualification" = "Granted after your friend completes qualifying usage";
|
||||||
|
"account.referral.friendRewardQualification" = "Your friend also receives %@ credits after qualifying usage";
|
||||||
|
"account.referral.shareShort" = "Share";
|
||||||
|
"account.referral.inviteTitle" = "Invite";
|
||||||
|
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
|
||||||
|
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
|
||||||
|
"account.referral.summaryCompact" = "Rewarded %d · Pending %d";
|
||||||
|
"account.referral.preparingLink" = "Preparing your invitation link…";
|
||||||
|
"account.referral.createCode" = "Create invitation code";
|
||||||
|
"account.referral.status.pending" = "Pending";
|
||||||
|
"account.referral.status.rewarded" = "Rewarded";
|
||||||
|
"account.referral.status.ineligible" = "Ineligible";
|
||||||
|
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
|
||||||
|
"account.security.section" = "Account controls";
|
||||||
|
"account.signOut.action" = "Sign Out";
|
||||||
|
"account.signOut.confirmTitle" = "Sign out?";
|
||||||
|
"account.signOut.confirmMessage" = "On-device features and your own API keys will keep working.";
|
||||||
|
"account.delete.action" = "Delete Account";
|
||||||
|
"account.delete.confirmTitle" = "Permanently delete this account?";
|
||||||
|
"account.delete.confirmMessage" = "Credits, invitation status, and server account data will be deleted. This cannot be undone.";
|
||||||
|
"account.delete.continue" = "Continue to Reauthenticate";
|
||||||
|
"account.delete.reauthenticateTitle" = "Confirm with Apple";
|
||||||
|
"account.delete.reauthenticateBody" = "Apple reauthentication is required before OSGKeyboard can permanently delete the account.";
|
||||||
|
"account.delete.reauthenticate" = "Continue with Apple";
|
||||||
|
"account.delete.reauthenticateHint" = "Reauthenticates and permanently deletes your OSG account.";
|
||||||
|
"account.delete.loading" = "Deleting account…";
|
||||||
|
"account.retry" = "Try Again";
|
||||||
|
"account.error.title" = "Account unavailable";
|
||||||
|
"account.error.unavailable" = "Account services are not connected in this build.";
|
||||||
|
"account.error.restore" = "Could not restore your account session.";
|
||||||
|
"account.error.signIn" = "Could not sign in. Please try again.";
|
||||||
|
"account.error.load" = "Could not load account details.";
|
||||||
|
"account.error.invalidReferralCode" = "The server returned an invalid invitation code.";
|
||||||
|
"account.error.createReferralCode" = "Could not create an invitation code.";
|
||||||
|
"account.error.signOut" = "Could not sign out. Please try again.";
|
||||||
|
"account.error.delete" = "Could not delete the account.";
|
||||||
|
"account.error.redeemReferral" = "The invitation is saved, but could not be redeemed yet.";
|
||||||
|
"account.error.appleAuthorization" = "Apple authorization could not be completed.";
|
||||||
|
"account.error.managedGateway" = "Managed access could not be prepared. Check your balance and try again.";
|
||||||
|
"account.managed.section" = "Service credentials";
|
||||||
|
"account.managed.credentialSource" = "Credential source";
|
||||||
|
"account.managed.byok" = "My API keys";
|
||||||
|
"account.managed.accountCredits" = "Account credits";
|
||||||
|
"account.managed.explanation" = "This choice is independent from local or cloud speech recognition. Account sessions remain private to the host app; the keyboard receives only a short-lived, limited grant.";
|
||||||
|
"account.managed.preparing" = "Preparing managed access…";
|
||||||
|
|||||||
@@ -104,6 +104,17 @@
|
|||||||
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态;剪贴板历史会保留。";
|
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态;剪贴板历史会保留。";
|
||||||
"settings.reset.confirm" = "重置所有设置";
|
"settings.reset.confirm" = "重置所有设置";
|
||||||
"settings.engine.title" = "语音转写与润色";
|
"settings.engine.title" = "语音转写与润色";
|
||||||
|
"settings.aiService.title" = "AI 服务";
|
||||||
|
"settings.aiService.credits.title" = "使用积分";
|
||||||
|
"settings.aiService.credits.subtitle" = "由 OSG 使用积分完成语音转写和文本润色。";
|
||||||
|
"settings.aiService.credits.signIn" = "请先在上方登录账号后使用 OSG 积分。";
|
||||||
|
"settings.aiService.credits.consent.title" = "使用 OSG 托管云服务?";
|
||||||
|
"settings.aiService.credits.consent.message" = "使用积分时,录音会经 OSG 托管网关发送到托管语音服务商;转写文字、光标附近上下文和 AI 问题也可能经网关发送,用于润色或生成回答。请求会关联你的 OSG 账号,用于授权和积分扣费。本地识别和你自己的 API Key 不受影响。";
|
||||||
|
"settings.aiService.credits.consent.accept" = "同意并使用积分";
|
||||||
|
"settings.aiService.byok.title" = "我有 API Key";
|
||||||
|
"settings.aiService.byok.subtitle" = "使用 Apple 设备端识别或你自己的云服务。";
|
||||||
|
"settings.asr.apple.title" = "Apple 设备端识别";
|
||||||
|
"settings.asr.apple.subtitle" = "在本机私密转写,无需 ASR API Key。";
|
||||||
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
|
"settings.engine.subtitle" = "本地:系统内置转写;云端:ASR 转写,可用 LLM 润色。";
|
||||||
"settings.engine.local.title" = "本地转写";
|
"settings.engine.local.title" = "本地转写";
|
||||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||||
@@ -205,20 +216,23 @@
|
|||||||
"settings.clipboard.subtitle.on" = "已开启";
|
"settings.clipboard.subtitle.on" = "已开启";
|
||||||
"settings.clipboard.subtitle.off" = "已关闭";
|
"settings.clipboard.subtitle.off" = "已关闭";
|
||||||
"settings.clipboard.history.title" = "历史记录";
|
"settings.clipboard.history.title" = "历史记录";
|
||||||
"settings.clipboard.history.footer" = "默认关闭。开启后采集本机或通用剪贴板复制的文本,并在本机 App Group 中保存最近 15 条;复制约 30 秒内,AI 模式也可展示剪贴板相关建议。";
|
"settings.clipboard.history.footer" = "在本机保存最近 15 条复制文字,默认关闭。";
|
||||||
"settings.clipboard.candidate.title" = "建议条";
|
"settings.clipboard.candidate.title" = "建议条";
|
||||||
"settings.clipboard.candidate.footer" = "最新复制显示在键盘上方,点一下即可插入。";
|
"settings.clipboard.candidate.footer" = "在键盘上方显示最新复制,轻点即可插入。";
|
||||||
"settings.clipboard.paste.section" = "系统授权";
|
"settings.clipboard.paste.section" = "系统授权";
|
||||||
"settings.clipboard.paste.body" = "先复制一段文字,再点「请求粘贴权限」并允许访问;iOS 随后会生成「从其他 App 粘贴」设置项,可将其设为「允许」。";
|
"settings.clipboard.paste.body" = "允许剪贴板技能读取你主动复制的文字。";
|
||||||
"settings.clipboard.paste.request" = "请求粘贴权限";
|
"settings.clipboard.paste.request" = "验证粘贴访问";
|
||||||
|
"settings.clipboard.paste.verified" = "已验证";
|
||||||
"settings.clipboard.paste.open" = "打开系统设置";
|
"settings.clipboard.paste.open" = "打开系统设置";
|
||||||
"settings.clipboard.storage.section" = "本机历史";
|
"settings.clipboard.storage.section" = "本机历史";
|
||||||
"settings.clipboard.storage.body" = "关闭「历史记录」只会停止采集并关闭建议条,已有记录仍会保留。剪贴板历史不经 iCloud 同步,也不会自动发送给 AI;插入后若主动使用润色,内容可能作为上下文发送给你配置的服务商。敏感内容过滤采用保守规则,无法识别所有密码。";
|
"settings.clipboard.storage.body" = "历史仅保存在本机,不经 iCloud 同步,也不会自动发送给 AI。关闭历史不会删除已有记录。";
|
||||||
|
"clipboard.paste.noText.title" = "请先复制文字";
|
||||||
|
"clipboard.paste.noText.message" = "复制一小段文字后,再试一次。";
|
||||||
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
"settings.clipboard.clear.button" = "清空剪贴板历史";
|
||||||
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
||||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
||||||
"settings.clipboard.clear.confirm" = "清空历史";
|
"settings.clipboard.clear.confirm" = "清空历史";
|
||||||
"settings.typingInput.title" = "文本输入";
|
"settings.typingInput.title" = "拼音输入";
|
||||||
"settings.typingInput.default.title" = "默认输入方式";
|
"settings.typingInput.default.title" = "默认输入方式";
|
||||||
"settings.typingInput.default.description" = "打开键盘时使用此方式";
|
"settings.typingInput.default.description" = "打开键盘时使用此方式";
|
||||||
"settings.typingInput.default.mode.voice" = "语音";
|
"settings.typingInput.default.mode.voice" = "语音";
|
||||||
@@ -251,7 +265,7 @@
|
|||||||
"settings.handedness.title" = "握持偏好";
|
"settings.handedness.title" = "握持偏好";
|
||||||
"settings.handedness.left" = "左手";
|
"settings.handedness.left" = "左手";
|
||||||
"settings.handedness.right" = "右手";
|
"settings.handedness.right" = "右手";
|
||||||
"settings.keyboardHaptic.title" = "震动";
|
"settings.keyboardHaptic.title" = "按键震动";
|
||||||
"settings.keyboardHaptic.off" = "关";
|
"settings.keyboardHaptic.off" = "关";
|
||||||
"settings.keyboardHaptic.light" = "轻";
|
"settings.keyboardHaptic.light" = "轻";
|
||||||
"settings.keyboardHaptic.strong" = "强";
|
"settings.keyboardHaptic.strong" = "强";
|
||||||
@@ -268,9 +282,9 @@
|
|||||||
"settings.privacy.policy" = "隐私政策";
|
"settings.privacy.policy" = "隐私政策";
|
||||||
"settings.privacy.fullAccess.title" = "关于完全访问";
|
"settings.privacy.fullAccess.title" = "关于完全访问";
|
||||||
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
|
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
|
||||||
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
|
"settings.privacy.cloud.body" = "使用自备 API Key 时,云端请求会直接发送到你配置的服务商;选择 OSG 积分时,音频和文字请求会经 OSG 托管网关发送到托管服务商。";
|
||||||
"settings.privacy.cloud.alert.title" = "音频将离开你的设备";
|
"settings.privacy.cloud.alert.title" = "云端处理会将数据发送到设备外";
|
||||||
"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
"settings.privacy.cloud.alert.message" = "所选云端路径会把录音和相关文字发送到设备外,用于转写、润色或 AI 回答。用户自备请求直达所选服务商;托管积分请求经 OSG 托管网关处理。";
|
||||||
"settings.link.support" = "帮助与反馈";
|
"settings.link.support" = "帮助与反馈";
|
||||||
"settings.link.github" = "GitHub";
|
"settings.link.github" = "GitHub";
|
||||||
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
|
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
|
||||||
@@ -439,9 +453,16 @@
|
|||||||
"skills.enabled.section" = "使用中(%d/%d)";
|
"skills.enabled.section" = "使用中(%d/%d)";
|
||||||
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
|
||||||
"skills.available.section" = "可添加";
|
"skills.available.section" = "可添加";
|
||||||
"skills.clipboard.guide.title" = "需要剪贴板权限";
|
"skills.clipboard.guide.title" = "启用剪贴板技能";
|
||||||
"skills.clipboard.guide.body" = "请先在 App 里打开剪贴板历史,再到系统设置中允许粘贴授权,复制后才能使用这些技能。";
|
"skills.clipboard.guide.body" = "复制文字后,可直接总结、翻译或提取事项。";
|
||||||
"skills.clipboard.guide.openAppSettings" = "打开剪贴板设置";
|
"skills.clipboard.guide.enableHistory" = "开启历史记录";
|
||||||
|
"skills.clipboard.guide.verify.title" = "还差一步";
|
||||||
|
"skills.clipboard.guide.verify.body" = "允许 OSGKeyboard 读取你主动复制的文字。";
|
||||||
|
"skills.clipboard.guide.verify.action" = "验证粘贴访问";
|
||||||
|
"skills.clipboard.guide.recovery.title" = "粘贴访问未开启";
|
||||||
|
"skills.clipboard.guide.recovery.body" = "在系统设置中允许「从其他 App 粘贴」,返回后再验证。";
|
||||||
|
"skills.clipboard.guide.success.title" = "剪贴板技能已就绪";
|
||||||
|
"skills.clipboard.guide.success.body" = "复制文字后,即可从键盘使用这些技能。";
|
||||||
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
"skills.clipboard.guide.openSystemSettings" = "打开系统设置";
|
||||||
"skills.detail.title" = "技能";
|
"skills.detail.title" = "技能";
|
||||||
"skills.badge.default" = "默认技能";
|
"skills.badge.default" = "默认技能";
|
||||||
@@ -659,3 +680,127 @@
|
|||||||
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
||||||
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
||||||
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
|
"flow.error.clipboardUnavailable" = "没有可用的剪贴板内容。请开启剪贴板历史并先复制文本。";
|
||||||
|
|
||||||
|
/* 可选账号中心 */
|
||||||
|
"account.title" = "账号";
|
||||||
|
"account.settings.section" = "账号";
|
||||||
|
"account.settings.restoring" = "检查中…";
|
||||||
|
"account.settings.signedOut" = "未登录";
|
||||||
|
"account.settings.signedIn" = "已登录";
|
||||||
|
"account.settings.balance %lld" = "%lld 积分";
|
||||||
|
"account.loading.session" = "正在检查账号状态…";
|
||||||
|
"account.loading.center" = "正在加载账号…";
|
||||||
|
"account.signedOut.title" = "OSG 账号";
|
||||||
|
"account.signedOut.body" = "登录后可使用托管积分与邀请功能。";
|
||||||
|
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
|
||||||
|
"account.signIn.apple" = "通过 Apple 登录";
|
||||||
|
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
|
||||||
|
"account.signIn.loading" = "正在登录…";
|
||||||
|
"account.summary.section" = "账号";
|
||||||
|
"account.summary.signedIn" = "已登录";
|
||||||
|
"account.summary.protected" = "账号已安全保护";
|
||||||
|
"account.profile.section" = "个人资料";
|
||||||
|
"account.profile.fallbackName" = "OSG 用户";
|
||||||
|
"account.profile.nickname" = "昵称";
|
||||||
|
"account.profile.save" = "保存昵称";
|
||||||
|
"account.profile.editTitle" = "编辑";
|
||||||
|
"account.profile.editMessage" = "请输入账号中显示的昵称。";
|
||||||
|
"account.error.updateProfile" = "无法更新昵称。";
|
||||||
|
"account.summary.identifier" = "账号标识";
|
||||||
|
"account.credits.section" = "积分";
|
||||||
|
"account.credits.balance" = "可用积分";
|
||||||
|
"account.credits.unit" = "可用积分";
|
||||||
|
"account.credits.remaining" = "还剩余";
|
||||||
|
"account.credits.used" = "已使用";
|
||||||
|
"account.credits.total" = "总积分";
|
||||||
|
"account.credits.remainingCompact" = "%@";
|
||||||
|
"account.credits.usedCompact" = "已用 %@";
|
||||||
|
"account.credits.totalValueCompact" = "总量 %@";
|
||||||
|
"account.credits.updating" = "正在更新积分…";
|
||||||
|
"account.credits.updatedAt" = "更新于 %@";
|
||||||
|
"account.credits.updateFailedAt" = "更新失败 · 显示 %@ 的数据";
|
||||||
|
"account.credits.notUpdated" = "积分尚未更新";
|
||||||
|
"account.storekit.section" = "购买积分";
|
||||||
|
"account.storekit.credits" = "积分";
|
||||||
|
"account.storekit.purchase" = "购买";
|
||||||
|
"account.storekit.loading" = "正在加载 App Store 商品…";
|
||||||
|
"account.storekit.pending" = "购买正在等待批准。";
|
||||||
|
"account.storekit.succeeded" = "已增加 %lld 积分";
|
||||||
|
"account.storekit.noRestore" = "积分包为消耗型商品,不支持恢复购买。";
|
||||||
|
"account.storekit.error.productUnavailable" = "App Store 暂未提供此积分包。";
|
||||||
|
"account.storekit.error.verification" = "App Store 无法验证这笔交易。";
|
||||||
|
"account.storekit.error.server" = "账号服务器暂时无法记录这笔购买。";
|
||||||
|
"account.storekit.error.session" = "请重新登录后再购买积分。";
|
||||||
|
"account.storekit.error.network" = "请联网后重试。";
|
||||||
|
"account.storekit.error.conflict" = "这笔交易无法应用到当前账号。";
|
||||||
|
"account.storekit.error.unknown" = "购买未能完成。";
|
||||||
|
"account.purchaseHistory.title" = "购买记录";
|
||||||
|
"account.purchaseHistory.loading" = "正在加载购买记录…";
|
||||||
|
"account.purchaseHistory.empty" = "暂无购买记录";
|
||||||
|
"account.purchaseHistory.error" = "无法加载购买记录,请重试。";
|
||||||
|
"account.purchaseHistory.loadMoreError" = "加载更多记录失败,点击重试";
|
||||||
|
"account.purchaseHistory.balanceAfter" = "入账后 %@";
|
||||||
|
"account.purchaseHistory.status.credited" = "已入账";
|
||||||
|
"account.ledger.section" = "积分记录";
|
||||||
|
"account.ledger.empty" = "还没有积分记录";
|
||||||
|
"account.ledger.defaultReason" = "积分变动";
|
||||||
|
"account.rates.section" = "费率";
|
||||||
|
"account.rates.empty" = "暂无托管服务费率";
|
||||||
|
"account.rate.asr.seconds" = "%lld 积分 / %lld 秒";
|
||||||
|
"account.rate.asr.milliseconds" = "%lld 积分 / %lld 毫秒";
|
||||||
|
"account.rate.llm" = "输入:%lld 积分 / %lld tokens · 输出:%lld 积分 / %lld tokens";
|
||||||
|
"account.referral.section" = "邀请";
|
||||||
|
"account.referral.yourCode" = "你的邀请码";
|
||||||
|
"account.referral.notCreated" = "尚未生成";
|
||||||
|
"account.rewards.title" = "积分与邀请";
|
||||||
|
"account.rewards.details" = "查看积分与邀请详情";
|
||||||
|
"account.referral.share" = "分享邀请";
|
||||||
|
"account.referral.inviteAction" = "邀请好友赚积分";
|
||||||
|
"account.referral.rewardExplanation" = "好友通过链接注册并完成一次有效使用后,你和好友均可获得积分。";
|
||||||
|
"account.referral.rewardGeneric" = "邀请好友赚积分";
|
||||||
|
"account.referral.rewardPerFriend" = "每成功邀请 1 位好友,你可获得 %@ 积分";
|
||||||
|
"account.referral.qualification" = "好友完成首次有效使用后发放";
|
||||||
|
"account.referral.friendRewardQualification" = "好友也可获得 %@ 积分,完成首次有效使用后发放";
|
||||||
|
"account.referral.shareShort" = "分享";
|
||||||
|
"account.referral.inviteTitle" = "邀请";
|
||||||
|
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。";
|
||||||
|
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
|
||||||
|
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
|
||||||
|
"account.referral.preparingLink" = "正在生成邀请链接…";
|
||||||
|
"account.referral.createCode" = "生成邀请码";
|
||||||
|
"account.referral.status.pending" = "待确认";
|
||||||
|
"account.referral.status.rewarded" = "已奖励";
|
||||||
|
"account.referral.status.ineligible" = "不符合条件";
|
||||||
|
"account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。";
|
||||||
|
"account.security.section" = "账号操作";
|
||||||
|
"account.signOut.action" = "退出登录";
|
||||||
|
"account.signOut.confirmTitle" = "退出登录?";
|
||||||
|
"account.signOut.confirmMessage" = "本地功能与自有 API Key 仍可继续使用。";
|
||||||
|
"account.delete.action" = "注销账号";
|
||||||
|
"account.delete.confirmTitle" = "永久注销此账号?";
|
||||||
|
"account.delete.confirmMessage" = "积分、邀请状态及服务端账号数据都会被删除,且无法撤销。";
|
||||||
|
"account.delete.continue" = "继续重新认证";
|
||||||
|
"account.delete.reauthenticateTitle" = "通过 Apple 确认";
|
||||||
|
"account.delete.reauthenticateBody" = "永久注销账号前,OSGKeyboard 需要通过 Apple 重新认证。";
|
||||||
|
"account.delete.reauthenticate" = "通过 Apple 继续";
|
||||||
|
"account.delete.reauthenticateHint" = "重新认证并永久注销 OSG 账号。";
|
||||||
|
"account.delete.loading" = "正在注销账号…";
|
||||||
|
"account.retry" = "重试";
|
||||||
|
"account.error.title" = "账号暂不可用";
|
||||||
|
"account.error.unavailable" = "当前构建尚未接入账号服务。";
|
||||||
|
"account.error.restore" = "无法恢复账号会话。";
|
||||||
|
"account.error.signIn" = "登录失败,请重试。";
|
||||||
|
"account.error.load" = "无法加载账号详情。";
|
||||||
|
"account.error.invalidReferralCode" = "服务端返回了无效的邀请码。";
|
||||||
|
"account.error.createReferralCode" = "无法生成邀请码。";
|
||||||
|
"account.error.signOut" = "退出失败,请重试。";
|
||||||
|
"account.error.delete" = "无法注销账号。";
|
||||||
|
"account.error.redeemReferral" = "邀请已暂存,但暂时无法兑换。";
|
||||||
|
"account.error.appleAuthorization" = "未能完成 Apple 授权。";
|
||||||
|
"account.error.managedGateway" = "无法准备托管服务,请检查积分后重试。";
|
||||||
|
"account.managed.section" = "服务凭证";
|
||||||
|
"account.managed.credentialSource" = "凭证来源";
|
||||||
|
"account.managed.byok" = "我的 API 密钥";
|
||||||
|
"account.managed.accountCredits" = "账号积分";
|
||||||
|
"account.managed.explanation" = "该选择与本地或云端语音识别相互独立。账号会话仅保存在主 App,键盘只能获得短时、受限的网关授权。";
|
||||||
|
"account.managed.preparing" = "正在准备托管服务…";
|
||||||
|
|||||||
@@ -7,9 +7,30 @@
|
|||||||
// dyld (usually host coexistence or an oversized Shared+Rime mapping).
|
// dyld (usually host coexistence or an oversized Shared+Rime mapping).
|
||||||
|
|
||||||
#import <Foundation/Foundation.h>
|
#import <Foundation/Foundation.h>
|
||||||
|
#include <mach/mach.h>
|
||||||
|
#include <mach/task_info.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
static double OSGKeyboardExtPhysFootprintMB(void) {
|
||||||
|
task_vm_info_data_t info = {0};
|
||||||
|
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||||
|
kern_return_t result = task_info(
|
||||||
|
mach_task_self(),
|
||||||
|
TASK_VM_INFO,
|
||||||
|
(task_info_t)&info,
|
||||||
|
&count
|
||||||
|
);
|
||||||
|
if (result != KERN_SUCCESS) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return (double)info.phys_footprint / 1048576.0;
|
||||||
|
}
|
||||||
|
|
||||||
__attribute__((constructor))
|
__attribute__((constructor))
|
||||||
static void OSGKeyboardExtBootProbe(void) {
|
static void OSGKeyboardExtBootProbe(void) {
|
||||||
NSLog(@"[OSGDiag/boot] dyld.constructor pid=%d", getpid());
|
NSLog(
|
||||||
|
@"[OSGDiag/boot] dyld.constructor pid=%d foot=%.1fMB",
|
||||||
|
getpid(),
|
||||||
|
OSGKeyboardExtPhysFootprintMB()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// OSGKeyboard · Keyboard Extension
|
// OSGKeyboard · Keyboard Extension
|
||||||
//
|
//
|
||||||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||||||
// SwiftUI tree (`KeyboardRootView`) and wires Flow voice input:
|
// SwiftUI tree (`KeyboardSurfaceRoot`) and wires Flow voice / AI input:
|
||||||
//
|
//
|
||||||
// host app Flow session ──► App Group transcript ──► insertText
|
// host app Flow session ──► App Group transcript ──► insertText
|
||||||
//
|
//
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
// • `phase` is a real stored property (no derivation) — the previous
|
// • `phase` is a real stored property (no derivation) — the previous
|
||||||
// "derive from recordStream" shim locked out every press after the first.
|
// "derive from recordStream" shim locked out every press after the first.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
import Combine
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
|
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
|
||||||
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||||
@@ -63,6 +63,20 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
/// changes instead of every pass.
|
/// changes instead of every pass.
|
||||||
private var lastLoggedLayoutSnapshot: String?
|
private var lastLoggedLayoutSnapshot: String?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||||
|
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
private var memoryTelemetryContext: String {
|
||||||
|
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||||||
|
return "surface=\(state.surface.rawValue) language=\(language) "
|
||||||
|
+ "fullAccess=\(hasFullAccess ? 1 : 0) "
|
||||||
|
+ "clipboard=\(state.clipboardHistoryEnabled ? 1 : 0)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordMemory(_ stage: String, details: String? = nil) {
|
||||||
|
KeyboardExtensionMemoryTelemetry.updateContext(memoryTelemetryContext)
|
||||||
|
KeyboardExtensionMemoryTelemetry.record(stage, details: details)
|
||||||
|
}
|
||||||
|
|
||||||
private var editHintScheduler: EditHintScheduler!
|
private var editHintScheduler: EditHintScheduler!
|
||||||
private var textInserter: KeyboardTextInserter!
|
private var textInserter: KeyboardTextInserter!
|
||||||
@@ -71,10 +85,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
private var aiKeyboardCoordinator: AIKeyboardCoordinator!
|
||||||
private var clipboardCapture: ClipboardCaptureCoordinator!
|
private var clipboardCapture: ClipboardCaptureCoordinator!
|
||||||
private var configSync: KeyboardConfigSync!
|
private var configSync: KeyboardConfigSync!
|
||||||
/// UIKit may synchronously lay out the view during `viewDidLoad`.
|
|
||||||
/// Keep this optional so an early layout pass is harmless.
|
|
||||||
private var cursorDrag: CursorDragController?
|
|
||||||
|
|
||||||
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
/// iPad-scale keys require both an iPad host and regular horizontal space.
|
||||||
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
/// This keeps compact iPad multitasking on phone metrics and prevents wide
|
||||||
/// iPhones from being mistaken for iPads.
|
/// iPhones from being mistaken for iPads.
|
||||||
@@ -106,14 +116,22 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// MARK: - Init
|
// MARK: - Init
|
||||||
|
|
||||||
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||||
|
KeyboardExtensionMemoryTelemetry.begin(
|
||||||
|
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||||||
|
)
|
||||||
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
|
||||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||||
|
recordMemory("KVC.init.done")
|
||||||
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
|
||||||
}
|
}
|
||||||
|
|
||||||
public required init?(coder: NSCoder) {
|
public required init?(coder: NSCoder) {
|
||||||
|
KeyboardExtensionMemoryTelemetry.begin(
|
||||||
|
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||||||
|
)
|
||||||
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
|
||||||
super.init(coder: coder)
|
super.init(coder: coder)
|
||||||
|
recordMemory("KVC.init.done")
|
||||||
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +148,11 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||||
primaryLanguage = "mis"
|
primaryLanguage = "mis"
|
||||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||||
|
recordMemory(
|
||||||
|
"KVC.viewDidLoad.begin",
|
||||||
|
details: "preferredSurface=\(preferred.rawValue)"
|
||||||
|
)
|
||||||
|
KeyboardExtensionMemoryTelemetry.startBootSampling()
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
|
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
|
||||||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||||||
@@ -147,19 +170,31 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
installKeyboardHeight()
|
installKeyboardHeight()
|
||||||
configureDictationBehavior()
|
configureDictationBehavior()
|
||||||
installServices()
|
installServices()
|
||||||
|
recordMemory("KVC.viewDidLoad.afterInstallServices")
|
||||||
OSGDiag.log("KVC.viewDidLoad after installServices \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.viewDidLoad after installServices \(OSGDiag.memoryTag())", category: "boot")
|
||||||
// Apply open preference before mounting SwiftUI so the first frame is
|
// Apply open preference before mounting SwiftUI so the first frame is
|
||||||
// already voice or typing — avoids a visible surface flash.
|
// already voice or typing — avoids a visible surface flash.
|
||||||
applyPreferredSurfaceOnOpen()
|
applyPreferredSurfaceOnOpen()
|
||||||
|
recordMemory("KVC.viewDidLoad.afterPreferredSurface")
|
||||||
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
|
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
|
||||||
installTypingContextProviders()
|
installTypingContextProviders()
|
||||||
installStateActions()
|
installStateActions()
|
||||||
installSurfaceObservers()
|
installSurfaceObservers()
|
||||||
installSwiftUI()
|
installSwiftUI()
|
||||||
|
recordMemory("KVC.viewDidLoad.afterInstallSwiftUI")
|
||||||
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
|
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
|
||||||
_ = configSync.loadPersistedConfig()
|
let configLoadResult = configSync.loadPersistedConfig()
|
||||||
|
recordMemory(
|
||||||
|
"KVC.viewDidLoad.afterConfigLoad",
|
||||||
|
details: "result=\(configLoadResult)"
|
||||||
|
)
|
||||||
configSync.installDarwinObservers()
|
configSync.installDarwinObservers()
|
||||||
flowCoordinator.refreshSessionState()
|
flowCoordinator.refreshSessionState()
|
||||||
|
recordMemory(
|
||||||
|
"KVC.viewDidLoad.done",
|
||||||
|
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
|
||||||
|
+ "hostReady=\(FlowSessionBridge.isHostReady() ? 1 : 0)"
|
||||||
|
)
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
|
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
|
||||||
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
|
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
|
||||||
@@ -170,6 +205,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
clipboardCapture?.keyboardWillDisappear()
|
clipboardCapture?.keyboardWillDisappear()
|
||||||
// Presentation-scoped hints must never survive a reused extension
|
// Presentation-scoped hints must never survive a reused extension
|
||||||
// controller, including an active Flow handoff.
|
// controller, including an active Flow handoff.
|
||||||
@@ -179,6 +216,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
|
recordMemory(
|
||||||
|
"KVC.viewWillDisappear",
|
||||||
|
details: "preserve=\(flowCoordinator.preservesLifecycleOnDisappear ? 1 : 0)"
|
||||||
|
)
|
||||||
heightPhase = .idle
|
heightPhase = .idle
|
||||||
flowCoordinator.stopSessionMonitor()
|
flowCoordinator.stopSessionMonitor()
|
||||||
// Remember what the user left on, then pre-position a reused
|
// Remember what the user left on, then pre-position a reused
|
||||||
@@ -191,10 +232,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if state.surface == .typing {
|
if state.surface == .typing {
|
||||||
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
TypingInputConfiguration.persistLastTypingLanguage(typingSession.language)
|
||||||
}
|
}
|
||||||
if state.surface == .ai {
|
if state.aiSession.isActive {
|
||||||
// AI context never survives a keyboard presentation, but the
|
// Unified assistant conversations never survive a keyboard presentation.
|
||||||
// selected surface itself is restored on the next open.
|
|
||||||
TypingInputConfiguration.persistLastSurface(.ai)
|
|
||||||
aiKeyboardCoordinator.leave()
|
aiKeyboardCoordinator.leave()
|
||||||
}
|
}
|
||||||
if !preserve {
|
if !preserve {
|
||||||
@@ -217,6 +256,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
|
recordMemory("KVC.viewWillAppear.begin")
|
||||||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||||
configureDictationBehavior()
|
configureDictationBehavior()
|
||||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||||
@@ -231,12 +271,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||||||
// skips when the extension process is reused, leaving generators cold.
|
// skips when the extension process is reused, leaving generators cold.
|
||||||
KeyboardHapticFeedback.prepare()
|
KeyboardHapticFeedback.prepare()
|
||||||
|
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()
|
typingSession.enterTypingMode()
|
||||||
refreshEnglishSupplementaryLexicon()
|
refreshEnglishSupplementaryLexicon()
|
||||||
|
recordMemory("KVC.viewWillAppear.afterTypingEnter")
|
||||||
}
|
}
|
||||||
clipboardCapture.keyboardDidAppear()
|
clipboardCapture.keyboardDidAppear()
|
||||||
|
recordMemory("KVC.viewWillAppear.afterClipboard")
|
||||||
|
recordMemory("KVC.viewWillAppear.done")
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
@@ -265,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
|
recordMemory("KVC.viewDidAppear.begin")
|
||||||
disableSystemGestureDelays()
|
disableSystemGestureDelays()
|
||||||
heightPhase = .presented
|
heightPhase = .presented
|
||||||
lockPresentedKeyboardHeight()
|
lockPresentedKeyboardHeight()
|
||||||
@@ -280,16 +325,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
host: WhatsNewDemoDriver.HostHooks(
|
host: WhatsNewDemoDriver.HostHooks(
|
||||||
insertText: { [weak self] text in
|
insertText: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
deleteBackward: { [weak self] in
|
deleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
},
|
},
|
||||||
contextBeforeInput: { [weak self] in
|
contextBeforeInput: { [weak self] in
|
||||||
self?.textDocumentProxy.documentContextBeforeInput
|
self?.textDocumentProxy.documentContextBeforeInput
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -298,6 +343,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
|
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
|
recordMemory("KVC.viewDidAppear.done")
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func textDidChange(_ textInput: UITextInput?) {
|
public override func textDidChange(_ textInput: UITextInput?) {
|
||||||
@@ -310,6 +356,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()
|
||||||
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
typingSession.synchronizeEnglishDocumentContext(caretMoved: true)
|
||||||
textInserter?.refreshEditingAvailability()
|
textInserter?.refreshEditingAvailability()
|
||||||
lastInputEditCoordinator?.refreshContext()
|
lastInputEditCoordinator?.refreshContext()
|
||||||
@@ -333,6 +380,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
public override func didReceiveMemoryWarning() {
|
public override func didReceiveMemoryWarning() {
|
||||||
super.didReceiveMemoryWarning()
|
super.didReceiveMemoryWarning()
|
||||||
|
recordMemory("KVC.didReceiveMemoryWarning")
|
||||||
OSGDiag.log(
|
OSGDiag.log(
|
||||||
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
@@ -353,7 +401,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
// portrait→landscape resize. `refreshLayoutMode` no-ops unless the
|
||||||
// layout bucket actually changed, so this cannot loop.
|
// layout bucket actually changed, so this cannot loop.
|
||||||
refreshLayoutMode()
|
refreshLayoutMode()
|
||||||
cursorDrag?.layoutChrome()
|
|
||||||
enforcePresentedKeyboardHeightIfNeeded()
|
enforcePresentedKeyboardHeightIfNeeded()
|
||||||
logLayoutSnapshotIfChanged()
|
logLayoutSnapshotIfChanged()
|
||||||
}
|
}
|
||||||
@@ -364,8 +411,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
editHintScheduler = EditHintScheduler(state: state)
|
editHintScheduler = EditHintScheduler(state: state)
|
||||||
textInserter = KeyboardTextInserter(
|
textInserter = KeyboardTextInserter(
|
||||||
state: state,
|
state: state,
|
||||||
insertText: { [weak self] text in self?.textDocumentProxy.insertText(text) },
|
insertText: { [weak self] text in self?.insertTextIntoDocument(text) },
|
||||||
deleteBackward: { [weak self] in self?.textDocumentProxy.deleteBackward() },
|
deleteBackward: { [weak self] in self?.deleteBackwardFromDocument() },
|
||||||
contextBeforeInput: { [weak self] in self?.textDocumentProxy.documentContextBeforeInput },
|
contextBeforeInput: { [weak self] in 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 self?.textDocumentProxy.selectedText },
|
||||||
@@ -427,7 +474,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.textInserter.insertAIAnswer(answer) ?? false
|
self?.textInserter.insertAIAnswer(answer) ?? false
|
||||||
},
|
},
|
||||||
performReturn: { [weak self] in
|
performReturn: { [weak self] in
|
||||||
self?.textDocumentProxy.insertText("\n")
|
self?.performDocumentReturn()
|
||||||
|
},
|
||||||
|
captureInsertionFingerprint: { [weak self] in
|
||||||
|
self?.captureFieldContext().deliveryFingerprint
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
clipboardCapture = ClipboardCaptureCoordinator(state: state)
|
||||||
@@ -474,13 +524,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self?.state.aiSession.cancelCurrentWork()
|
self?.state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
_ = textInserter.recoverPendingEditTransactionIfNeeded()
|
||||||
|
|
||||||
cursorDrag = CursorDragController(
|
|
||||||
state: state,
|
|
||||||
adjustTextPosition: { [weak self] offset in
|
|
||||||
self?.textDocumentProxy.adjustTextPosition(byCharacterOffset: offset)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Wiring
|
// MARK: - Wiring
|
||||||
@@ -513,8 +556,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.cancelAIInput = { [weak self] in
|
state.cancelAIInput = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.cancel()
|
self?.aiKeyboardCoordinator.cancel()
|
||||||
}
|
}
|
||||||
state.sendAIAnswer = { [weak self] in
|
state.confirmPendingAIAnswer = { [weak self] in
|
||||||
self?.aiKeyboardCoordinator.sendLatestAnswer()
|
self?.aiKeyboardCoordinator.confirmPendingAnswer()
|
||||||
|
}
|
||||||
|
state.discardPendingAIAnswer = { [weak self] in
|
||||||
|
self?.aiKeyboardCoordinator.discardPendingAnswer()
|
||||||
|
}
|
||||||
|
state.performAssistantFieldAction = { [weak self] in
|
||||||
|
self?.aiKeyboardCoordinator.performCurrentFieldAction()
|
||||||
}
|
}
|
||||||
state.submitAIHint = { [weak self] card in
|
state.submitAIHint = { [weak self] card in
|
||||||
self?.aiKeyboardCoordinator.submitHintCard(card)
|
self?.aiKeyboardCoordinator.submitHintCard(card)
|
||||||
@@ -567,23 +616,15 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
state.setEngineMode = { [weak self] m in self?.configSync.persistEngineMode(m) }
|
||||||
state.setTranslationTargetLocaleId = { [weak self] id in
|
state.setTranslationTargetLocaleId = { [weak self] id in
|
||||||
self?.configSync.persistTranslationTargetLocaleId(id)
|
self?.configSync.persistTranslationTargetLocaleId(id)
|
||||||
|
self?.aiKeyboardCoordinator.resetConversationForConfigurationChange()
|
||||||
}
|
}
|
||||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
state.insertNewline = { [weak self] in self?.insertTextIntoDocument("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.insertTextIntoDocument(" ") }
|
||||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
state.deleteBackward = { [weak self] in self?.deleteBackwardFromDocument() }
|
||||||
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
state.undoLastInsertion = { [weak self] in self?.textInserter.undoLastInsertion() }
|
||||||
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
state.redoLastInsertion = { [weak self] in self?.textInserter.redoLastInsertion() }
|
||||||
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
state.copySelection = { [weak self] in self?.textInserter.copySelection() }
|
||||||
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
state.cutSelection = { [weak self] in self?.textInserter.cutSelection() }
|
||||||
state.moveCursorHorizontal = { [weak self] steps in
|
|
||||||
self?.cursorDrag?.moveCursorHorizontally(by: steps)
|
|
||||||
}
|
|
||||||
state.moveCursorVertical = { [weak self] steps in
|
|
||||||
self?.cursorDrag?.moveCursorVertically(by: steps)
|
|
||||||
}
|
|
||||||
state.setCursorDragActive = { [weak self] active in
|
|
||||||
self?.cursorDrag?.setCursorDragActive(active)
|
|
||||||
}
|
|
||||||
state.setSurface = { [weak self] surface in
|
state.setSurface = { [weak self] surface in
|
||||||
self?.applySurface(surface)
|
self?.applySurface(surface)
|
||||||
}
|
}
|
||||||
@@ -608,7 +649,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySurface(_ surface: State.Surface) {
|
private func applySurface(_ requestedSurface: State.Surface) {
|
||||||
|
// `.ai` is retained only to decode preferences written by older builds.
|
||||||
|
// The product now has one unified assistant surface.
|
||||||
|
let surface: State.Surface = requestedSurface == .ai ? .voice : requestedSurface
|
||||||
if surface == .typing, state.locksTypingSurface {
|
if surface == .typing, state.locksTypingSurface {
|
||||||
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
|
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
|
||||||
return
|
return
|
||||||
@@ -618,9 +662,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard state.surface != surface else {
|
guard state.surface != surface else {
|
||||||
if surface == .ai {
|
|
||||||
aiKeyboardCoordinator.enterIfNeeded()
|
|
||||||
}
|
|
||||||
refreshKeyboardHeight()
|
refreshKeyboardHeight()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -628,20 +669,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
"applySurface \(state.surface.rawValue) → \(surface.rawValue) \(OSGDiag.memoryTag())",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
let previousSurface = state.surface
|
|
||||||
if previousSurface == .ai, surface != .ai {
|
|
||||||
aiKeyboardCoordinator.leave()
|
|
||||||
}
|
|
||||||
state.surface = surface
|
state.surface = surface
|
||||||
|
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
|
||||||
if surface == .typing {
|
if surface == .typing {
|
||||||
typingSession.enterTypingMode()
|
typingSession.enterTypingMode()
|
||||||
refreshEnglishSupplementaryLexicon()
|
refreshEnglishSupplementaryLexicon()
|
||||||
} else {
|
} else {
|
||||||
typingSession.leaveTypingMode()
|
typingSession.leaveTypingMode()
|
||||||
}
|
}
|
||||||
if surface == .ai {
|
|
||||||
aiKeyboardCoordinator.enterIfNeeded()
|
|
||||||
}
|
|
||||||
refreshKeyboardHeight()
|
refreshKeyboardHeight()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,9 +697,6 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
if resolved == .typing, let language = preference.typingLanguage {
|
if resolved == .typing, let language = preference.typingLanguage {
|
||||||
_ = typingSession.setLanguage(language)
|
_ = typingSession.setLanguage(language)
|
||||||
}
|
}
|
||||||
if resolved == .ai {
|
|
||||||
aiKeyboardCoordinator.beginNewPresentation()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When not remembering, snap to the static open preference while hidden
|
/// When not remembering, snap to the static open preference while hidden
|
||||||
@@ -733,8 +765,49 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keeps field-action UI current even when a host app does not immediately
|
||||||
|
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||||
|
private func insertTextIntoDocument(_ text: String) {
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
textDocumentProxy.insertText(text)
|
||||||
|
// A non-empty insertion makes content actions available immediately.
|
||||||
|
// The next host callback remains the authoritative correction.
|
||||||
|
refreshAssistantFieldAction(hasTextOverride: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteBackwardFromDocument() {
|
||||||
|
textDocumentProxy.deleteBackward()
|
||||||
|
refreshAssistantFieldAction()
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performDocumentReturn() {
|
||||||
|
textDocumentProxy.insertText("\n")
|
||||||
|
scheduleAssistantFieldActionRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleAssistantFieldActionRefresh() {
|
||||||
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = Task { @MainActor [weak self] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
guard !Task.isCancelled, let self else { return }
|
||||||
|
self.refreshAssistantFieldAction()
|
||||||
|
self.assistantFieldActionRefreshTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshAssistantFieldAction(hasTextOverride: Bool? = nil) {
|
||||||
|
let role = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
||||||
|
state.returnKeyRole = role
|
||||||
|
state.assistantActionAvailable = role.assistantActionAvailable(
|
||||||
|
hasText: hasTextOverride ?? textDocumentProxy.hasText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshReturnKeyRole() {
|
private func refreshReturnKeyRole() {
|
||||||
state.returnKeyRole = returnKeyRole(for: textDocumentProxy.returnKeyType ?? .default)
|
assistantFieldActionRefreshTask?.cancel()
|
||||||
|
assistantFieldActionRefreshTask = nil
|
||||||
|
refreshAssistantFieldAction()
|
||||||
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
let isSecure = textDocumentProxy.isSecureTextEntry ?? false
|
||||||
state.setSecureTextEntry(isSecure)
|
state.setSecureTextEntry(isSecure)
|
||||||
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
clipboardCapture?.secureEntryDidChange(isSecure: isSecure)
|
||||||
@@ -903,10 +976,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state: state,
|
state: state,
|
||||||
typing: typingSession,
|
typing: typingSession,
|
||||||
onInsert: { [weak self] text in
|
onInsert: { [weak self] text in
|
||||||
self?.textDocumentProxy.insertText(text)
|
self?.insertTextIntoDocument(text)
|
||||||
},
|
},
|
||||||
onDeleteBackward: { [weak self] in
|
onDeleteBackward: { [weak self] in
|
||||||
self?.textDocumentProxy.deleteBackward()
|
self?.deleteBackwardFromDocument()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let host = KeyboardHostingController(rootView: root)
|
let host = KeyboardHostingController(rootView: root)
|
||||||
@@ -921,11 +994,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
|
||||||
])
|
])
|
||||||
host.didMove(toParent: self)
|
host.didMove(toParent: self)
|
||||||
hosting = host
|
hosting = host
|
||||||
cursorDrag?.install(on: view)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App context
|
// MARK: - App context
|
||||||
|
|||||||
@@ -14,22 +14,31 @@ final class AIKeyboardCoordinator {
|
|||||||
private let flow: KeyboardFlowCoordinator
|
private let flow: KeyboardFlowCoordinator
|
||||||
private let insertAnswer: (AIAnswer) -> Bool
|
private let insertAnswer: (AIAnswer) -> Bool
|
||||||
private let performReturn: () -> Void
|
private let performReturn: () -> Void
|
||||||
|
private let captureInsertionFingerprint: () -> String?
|
||||||
|
private var requestInsertionFingerprint: String?
|
||||||
|
private var conversationInsertionFingerprint: String?
|
||||||
|
private var hasConversationInsertionTarget = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
flow: KeyboardFlowCoordinator,
|
flow: KeyboardFlowCoordinator,
|
||||||
insertAnswer: @escaping (AIAnswer) -> Bool,
|
insertAnswer: @escaping (AIAnswer) -> Bool,
|
||||||
performReturn: @escaping () -> Void
|
performReturn: @escaping () -> Void,
|
||||||
|
captureInsertionFingerprint: @escaping () -> String?
|
||||||
) {
|
) {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.flow = flow
|
self.flow = flow
|
||||||
self.insertAnswer = insertAnswer
|
self.insertAnswer = insertAnswer
|
||||||
self.performReturn = performReturn
|
self.performReturn = performReturn
|
||||||
|
self.captureInsertionFingerprint = captureInsertionFingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
func beginNewPresentation() {
|
func beginNewPresentation() {
|
||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.enter()
|
state.aiSession.enter()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
conversationInsertionFingerprint = nil
|
||||||
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func enterIfNeeded() {
|
func enterIfNeeded() {
|
||||||
@@ -43,6 +52,9 @@ final class AIKeyboardCoordinator {
|
|||||||
}
|
}
|
||||||
endConversationIfNeeded()
|
endConversationIfNeeded()
|
||||||
state.aiSession.leave()
|
state.aiSession.leave()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
conversationInsertionFingerprint = nil
|
||||||
|
hasConversationInsertionTarget = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleMicrophone() {
|
func toggleMicrophone() {
|
||||||
@@ -51,17 +63,17 @@ final class AIKeyboardCoordinator {
|
|||||||
guard let utteranceID = state.aiSession.activeUtteranceID else { return }
|
guard let utteranceID = state.aiSession.activeUtteranceID else { return }
|
||||||
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||||
flow.stopAIRecording()
|
flow.stopAIRecording()
|
||||||
case .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
|
case .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
let disposition = flow.beginAIRecording(conversationID: conversationID)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
state.aiSession.fail(message(for: rejection), utteranceID: nil)
|
||||||
}
|
}
|
||||||
case .inactive:
|
case .inactive:
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
toggleMicrophone()
|
toggleMicrophone()
|
||||||
case .preparing, .recognizing, .generating:
|
case .preparing, .recognizing, .generating, .ready:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -69,7 +81,7 @@ final class AIKeyboardCoordinator {
|
|||||||
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
/// Tap a clipboard skill chip: same fail-closed material path as hint cards.
|
||||||
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
func submitClipboardSkill(_ skill: AIClipboardSkill) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
if skill.kind == .export {
|
if skill.kind == .export {
|
||||||
state.pendingClipboardSkillID = skill.id
|
state.pendingClipboardSkillID = skill.id
|
||||||
@@ -77,11 +89,14 @@ final class AIKeyboardCoordinator {
|
|||||||
} else {
|
} else {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
}
|
}
|
||||||
let instruction = AIClipboardSkillCatalog.instruction(
|
var instruction = AIClipboardSkillCatalog.instruction(
|
||||||
for: skill,
|
for: skill,
|
||||||
locale: AIHintLocaleResolver.packLocale(),
|
locale: AIHintLocaleResolver.packLocale(),
|
||||||
translationTargetLocaleId: state.translationTargetLocaleId
|
translationTargetLocaleId: state.translationTargetLocaleId
|
||||||
)
|
)
|
||||||
|
if skill.kind == .export {
|
||||||
|
instruction += "\nPreserve the source language, addresses, names, and proper nouns."
|
||||||
|
}
|
||||||
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
|
||||||
if let material {
|
if let material {
|
||||||
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
|
||||||
@@ -95,32 +110,37 @@ final class AIKeyboardCoordinator {
|
|||||||
if case .materialUnavailable = resolution {
|
if case .materialUnavailable = resolution {
|
||||||
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
|
||||||
}
|
}
|
||||||
submitResolvedPrompt(resolution, thinkingEnabled: skill.thinkingEnabled)
|
submitResolvedPrompt(
|
||||||
|
resolution,
|
||||||
|
taskKind: skill.managedGatewayTaskKind,
|
||||||
|
thinkingEnabled: skill.thinkingEnabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
/// Tap an idle hint card: resolve its material, skip the mic, ask the host.
|
||||||
func submitHintCard(_ card: AIHintCard) {
|
func submitHintCard(_ card: AIHintCard) {
|
||||||
guard canAcceptIdleSubmit else { return }
|
guard canAcceptIdleSubmit else { return }
|
||||||
enterIfNeeded()
|
prepareConversationForRequest()
|
||||||
let resolution = AIHintPool.resolvePrompt(
|
let resolution = AIHintPool.resolvePrompt(
|
||||||
for: card,
|
for: card,
|
||||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||||
)
|
)
|
||||||
submitResolvedPrompt(resolution)
|
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var canAcceptIdleSubmit: Bool {
|
private var canAcceptIdleSubmit: Bool {
|
||||||
switch state.aiSession.phase {
|
switch state.aiSession.phase {
|
||||||
case .inactive, .idle, .failed:
|
case .inactive, .idle, .awaitingSend, .inserted, .sent, .failed:
|
||||||
return true
|
return true
|
||||||
case .preparing, .listening, .recognizing, .generating,
|
case .preparing, .listening, .recognizing, .generating,
|
||||||
.ready, .awaitingSend, .inserted, .sent:
|
.ready:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func submitResolvedPrompt(
|
private func submitResolvedPrompt(
|
||||||
_ resolution: AIClipboardPrompt.Resolution,
|
_ resolution: AIClipboardPrompt.Resolution,
|
||||||
|
taskKind: ManagedGatewayTaskKind,
|
||||||
thinkingEnabled: Bool? = nil
|
thinkingEnabled: Bool? = nil
|
||||||
) {
|
) {
|
||||||
guard case .ready(let prompt) = resolution else {
|
guard case .ready(let prompt) = resolution else {
|
||||||
@@ -139,6 +159,7 @@ final class AIKeyboardCoordinator {
|
|||||||
let disposition = flow.submitAIQuestion(
|
let disposition = flow.submitAIQuestion(
|
||||||
text: prompt,
|
text: prompt,
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
|
taskKind: taskKind,
|
||||||
thinkingEnabled: thinkingEnabled
|
thinkingEnabled: thinkingEnabled
|
||||||
)
|
)
|
||||||
if case .rejected(let rejection) = disposition {
|
if case .rejected(let rejection) = disposition {
|
||||||
@@ -150,23 +171,36 @@ final class AIKeyboardCoordinator {
|
|||||||
func cancel() {
|
func cancel() {
|
||||||
guard state.aiSession.isBusy else { return }
|
guard state.aiSession.isBusy else { return }
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
flow.cancelAIRecording()
|
flow.cancelAIRecording()
|
||||||
state.aiSession.cancelCurrentWork()
|
state.aiSession.cancelCurrentWork()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendLatestAnswer() {
|
func confirmPendingAnswer() {
|
||||||
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
if state.aiSession.canInsert, let answer = state.aiSession.answer {
|
||||||
guard insertAnswer(answer) else { return }
|
guard insertAnswer(answer) else { return }
|
||||||
state.aiSession.markAnswerInserted(
|
state.aiSession.markAnswerInserted(
|
||||||
offersSend: state.returnKeyRole.usesActionFill
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
)
|
)
|
||||||
} else if state.aiSession.canSend {
|
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardPendingAnswer() {
|
||||||
|
state.aiSession.discardReadyAnswer()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func performCurrentFieldAction() {
|
||||||
|
guard state.assistantActionAvailable else { return }
|
||||||
|
if state.aiSession.canSend {
|
||||||
state.aiSession.markAnswerSent()
|
state.aiSession.markAnswerSent()
|
||||||
// Let the host consume the inserted answer before issuing Return.
|
}
|
||||||
Task { @MainActor [weak self] in
|
// Let the host consume the inserted answer before issuing Return.
|
||||||
await Task.yield()
|
Task { @MainActor [weak self] in
|
||||||
self?.performReturn()
|
await Task.yield()
|
||||||
}
|
self?.performReturn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,13 +264,36 @@ final class AIKeyboardCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId)
|
state.aiSession.receiveAnswer(answer, utteranceID: result.utteranceId)
|
||||||
|
defer { requestInsertionFingerprint = nil }
|
||||||
|
guard state.aiSession.canInsert,
|
||||||
|
let expected = requestInsertionFingerprint,
|
||||||
|
captureInsertionFingerprint() == expected,
|
||||||
|
let answer = state.aiSession.answer,
|
||||||
|
insertAnswer(answer) else {
|
||||||
|
// Keep `.ready`: the unified UI presents an explicit Insert / Discard
|
||||||
|
// fallback when the field or caret changed during generation.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.aiSession.markAnswerInserted(
|
||||||
|
offersSend: state.returnKeyRole.usesActionFill
|
||||||
|
)
|
||||||
|
conversationInsertionFingerprint = captureInsertionFingerprint()
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func fail(_ message: String, utteranceID: UUID?) {
|
func fail(_ message: String, utteranceID: UUID?) {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
|
requestInsertionFingerprint = nil
|
||||||
state.aiSession.fail(message, utteranceID: utteranceID)
|
state.aiSession.fail(message, utteranceID: utteranceID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A global output-language change starts a clean conversation so retained
|
||||||
|
/// turns cannot override the newly selected language policy.
|
||||||
|
func resetConversationForConfigurationChange() {
|
||||||
|
guard state.aiSession.isActive else { return }
|
||||||
|
beginNewPresentation()
|
||||||
|
}
|
||||||
|
|
||||||
private func endConversationIfNeeded() {
|
private func endConversationIfNeeded() {
|
||||||
clearPendingExportSkill()
|
clearPendingExportSkill()
|
||||||
guard let conversationID = state.aiSession.conversationID else { return }
|
guard let conversationID = state.aiSession.conversationID else { return }
|
||||||
@@ -248,6 +305,20 @@ final class AIKeyboardCoordinator {
|
|||||||
state.pendingClipboardSkillSource = nil
|
state.pendingClipboardSkillSource = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func prepareConversationForRequest() {
|
||||||
|
let currentFingerprint = captureInsertionFingerprint()
|
||||||
|
if state.aiSession.isActive,
|
||||||
|
hasConversationInsertionTarget,
|
||||||
|
conversationInsertionFingerprint != currentFingerprint {
|
||||||
|
beginNewPresentation()
|
||||||
|
} else {
|
||||||
|
enterIfNeeded()
|
||||||
|
}
|
||||||
|
conversationInsertionFingerprint = currentFingerprint
|
||||||
|
hasConversationInsertionTarget = true
|
||||||
|
requestInsertionFingerprint = currentFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
private var isPendingExportSkill: Bool {
|
private var isPendingExportSkill: Bool {
|
||||||
guard let id = state.pendingClipboardSkillID else { return false }
|
guard let id = state.pendingClipboardSkillID else { return false }
|
||||||
return resolvedSkill(id: id)?.kind == .export
|
return resolvedSkill(id: id)?.kind == .export
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ public struct AppGroupPersistor {
|
|||||||
// the keyboard stays open.
|
// the keyboard stays open.
|
||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
@@ -96,7 +95,6 @@ public struct AppGroupPersistor {
|
|||||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||||
}
|
}
|
||||||
state.handednessPreference = store.handednessPreference
|
state.handednessPreference = store.handednessPreference
|
||||||
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
|
|
||||||
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
|
||||||
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
|
||||||
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
|
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol ClipboardPasteboardProviding: AnyObject {
|
protocol ClipboardPasteboardProviding: AnyObject {
|
||||||
@@ -65,7 +65,17 @@ final class ClipboardCaptureCoordinator {
|
|||||||
|
|
||||||
func keyboardDidAppear() {
|
func keyboardDidAppear() {
|
||||||
isKeyboardVisible = true
|
isKeyboardVisible = true
|
||||||
|
KeyboardExtensionMemoryTelemetry.record(
|
||||||
|
"clipboard.reload.begin",
|
||||||
|
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
|
||||||
|
+ "entries=\(history.entries.count)"
|
||||||
|
)
|
||||||
history.reload()
|
history.reload()
|
||||||
|
KeyboardExtensionMemoryTelemetry.record(
|
||||||
|
"clipboard.reload.done",
|
||||||
|
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
|
||||||
|
+ "entries=\(history.entries.count)"
|
||||||
|
)
|
||||||
// A suggestion belongs to one keyboard presentation. Clear any
|
// A suggestion belongs to one keyboard presentation. Clear any
|
||||||
// presentation state left behind by a reused extension controller.
|
// presentation state left behind by a reused extension controller.
|
||||||
endCurrentSuggestion()
|
endCurrentSuggestion()
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
// CursorDragController.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// Cursor-drag hint chrome and batched caret moves via textDocumentProxy.
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class CursorDragController {
|
|
||||||
private let state: KeyboardState
|
|
||||||
private let adjustTextPosition: (Int) -> Void
|
|
||||||
private weak var parentView: UIView?
|
|
||||||
|
|
||||||
private var cursorDragHintLabel: UILabel?
|
|
||||||
private var pendingHorizontalCursorSteps = 0
|
|
||||||
private var pendingVerticalCursorSteps = 0
|
|
||||||
private var cursorMoveFlushScheduled = false
|
|
||||||
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
|
|
||||||
|
|
||||||
private static let cursorVerticalChunkSize = 20
|
|
||||||
|
|
||||||
init(
|
|
||||||
state: KeyboardState,
|
|
||||||
adjustTextPosition: @escaping (Int) -> Void
|
|
||||||
) {
|
|
||||||
self.state = state
|
|
||||||
self.adjustTextPosition = adjustTextPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
func install(on view: UIView) {
|
|
||||||
parentView = view
|
|
||||||
let hint = UILabel()
|
|
||||||
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
|
|
||||||
hint.font = .systemFont(ofSize: 22, weight: .medium)
|
|
||||||
hint.textColor = UIColor.label.withAlphaComponent(0.10)
|
|
||||||
hint.textAlignment = .center
|
|
||||||
hint.numberOfLines = 1
|
|
||||||
hint.adjustsFontSizeToFitWidth = true
|
|
||||||
hint.minimumScaleFactor = 0.7
|
|
||||||
hint.isUserInteractionEnabled = false
|
|
||||||
hint.isHidden = true
|
|
||||||
hint.alpha = 0
|
|
||||||
view.addSubview(hint)
|
|
||||||
cursorDragHintLabel = hint
|
|
||||||
layoutChrome()
|
|
||||||
}
|
|
||||||
|
|
||||||
func layoutChrome() {
|
|
||||||
guard let view = parentView else { return }
|
|
||||||
cursorDragHintLabel?.frame = view.bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
func setCursorDragActive(_ active: Bool) {
|
|
||||||
state.cursorDragActive = active
|
|
||||||
updateCursorDragWash(active: active)
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveCursorHorizontally(by steps: Int) {
|
|
||||||
guard steps != 0 else { return }
|
|
||||||
pendingHorizontalCursorSteps += steps
|
|
||||||
scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
func moveCursorVertically(by steps: Int) {
|
|
||||||
guard steps != 0 else { return }
|
|
||||||
pendingVerticalCursorSteps += steps
|
|
||||||
scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateCursorDragWash(active: Bool) {
|
|
||||||
if active {
|
|
||||||
cursorLineHaptic.prepare()
|
|
||||||
}
|
|
||||||
layoutChrome()
|
|
||||||
guard let hint = cursorDragHintLabel else { return }
|
|
||||||
if active {
|
|
||||||
hint.isHidden = false
|
|
||||||
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
|
|
||||||
} else {
|
|
||||||
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
|
|
||||||
guard let self, !self.state.cursorDragActive else { return }
|
|
||||||
hint.isHidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scheduleCursorMoveFlush() {
|
|
||||||
guard !cursorMoveFlushScheduled else { return }
|
|
||||||
cursorMoveFlushScheduled = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.cursorMoveFlushScheduled = false
|
|
||||||
|
|
||||||
let horizontal = self.pendingHorizontalCursorSteps
|
|
||||||
let vertical = self.pendingVerticalCursorSteps
|
|
||||||
self.pendingHorizontalCursorSteps = 0
|
|
||||||
self.pendingVerticalCursorSteps = 0
|
|
||||||
|
|
||||||
if horizontal != 0 {
|
|
||||||
OSGLog.keyboardExt.info("adjustTextPosition h=\(horizontal)")
|
|
||||||
self.adjustTextPosition(horizontal)
|
|
||||||
}
|
|
||||||
|
|
||||||
if vertical != 0 {
|
|
||||||
self.applyVerticalCursorSteps(vertical)
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
|
|
||||||
self.scheduleCursorMoveFlush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyVerticalCursorSteps(_ steps: Int) {
|
|
||||||
let direction = steps > 0 ? 1 : -1
|
|
||||||
var remaining = abs(steps)
|
|
||||||
let chunk = Self.cursorVerticalChunkSize
|
|
||||||
|
|
||||||
while remaining > 0 {
|
|
||||||
adjustTextPosition(direction * chunk)
|
|
||||||
cursorLineHaptic.impactOccurred()
|
|
||||||
cursorLineHaptic.prepare()
|
|
||||||
remaining -= 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,9 +64,7 @@ final class KeyboardConfigSync {
|
|||||||
func loadPersistedConfig() -> AppGroupLoadResult {
|
func loadPersistedConfig() -> AppGroupLoadResult {
|
||||||
switch persistor.load(into: state) {
|
switch persistor.load(into: state) {
|
||||||
case .loaded:
|
case .loaded:
|
||||||
OSGLog.keyboardExt.info(
|
OSGLog.keyboardExt.info("config loaded")
|
||||||
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
|
|
||||||
)
|
|
||||||
syncOnboardingStateFromAppGroup()
|
syncOnboardingStateFromAppGroup()
|
||||||
return .loaded
|
return .loaded
|
||||||
case .unavailable:
|
case .unavailable:
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Flow session start, recording, watchdogs, and result delivery handling.
|
// Flow session start, recording, watchdogs, and result delivery handling.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class KeyboardFlowCoordinator {
|
final class KeyboardFlowCoordinator {
|
||||||
@@ -681,6 +681,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
func submitAIQuestion(
|
func submitAIQuestion(
|
||||||
text: String,
|
text: String,
|
||||||
conversationID: UUID,
|
conversationID: UUID,
|
||||||
|
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||||
thinkingEnabled: Bool? = nil
|
thinkingEnabled: Bool? = nil
|
||||||
) -> FlowUtteranceStartDisposition {
|
) -> FlowUtteranceStartDisposition {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -689,6 +690,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
.aiQuestion(
|
.aiQuestion(
|
||||||
conversationID: conversationID,
|
conversationID: conversationID,
|
||||||
prefilledQuestion: trimmed,
|
prefilledQuestion: trimmed,
|
||||||
|
taskKind: taskKind,
|
||||||
thinkingEnabled: thinkingEnabled
|
thinkingEnabled: thinkingEnabled
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1191,6 +1193,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
sourceHistoryEntryID: request.sourceHistoryEntryID,
|
sourceHistoryEntryID: request.sourceHistoryEntryID,
|
||||||
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
||||||
aiConversationID: request.aiConversationID,
|
aiConversationID: request.aiConversationID,
|
||||||
|
aiTaskKind: request.aiTaskKind,
|
||||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||||
? Date().timeIntervalSince1970
|
? Date().timeIntervalSince1970
|
||||||
@@ -1898,6 +1901,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
utteranceMode: .aiQuestion,
|
utteranceMode: .aiQuestion,
|
||||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||||
aiQuestionText: text,
|
aiQuestionText: text,
|
||||||
|
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
||||||
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||||
startDeadlineAt: currentStartDeadlineAt
|
startDeadlineAt: currentStartDeadlineAt
|
||||||
)
|
)
|
||||||
@@ -2034,7 +2038,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
onAIStreamingAnswer(result.text ?? "", result.utteranceId)
|
onAIStreamingAnswer(result.text ?? "", result.utteranceId)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (result.status == .partial || result.status == .rawReady),
|
if result.status == .partial || result.status == .rawReady,
|
||||||
let partial = result.text,
|
let partial = result.text,
|
||||||
!partial.isEmpty {
|
!partial.isEmpty {
|
||||||
if result.resolvedUtteranceMode == .aiQuestion,
|
if result.resolvedUtteranceMode == .aiQuestion,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// without re-running LLM polish in the extension. Also tracks the last
|
// without re-running LLM polish in the extension. Also tracks the last
|
||||||
// voice insertion so the undo button can roll it back safely.
|
// voice insertion so the undo button can roll it back safely.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class KeyboardTextInserter {
|
final class KeyboardTextInserter {
|
||||||
@@ -37,6 +37,7 @@ final class KeyboardTextInserter {
|
|||||||
/// Suppresses availability refresh while we walk `deleteBackward`
|
/// Suppresses availability refresh while we walk `deleteBackward`
|
||||||
/// for undo, so intermediate contexts don't flicker the button.
|
/// for undo, so intermediate contexts don't flicker the button.
|
||||||
private var isUndoing = false
|
private var isUndoing = false
|
||||||
|
private var successPulseTask: Task<Void, Never>?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
@@ -155,6 +156,7 @@ final class KeyboardTextInserter {
|
|||||||
// The paste pushed any previous input away from the caret, so the
|
// The paste pushed any previous input away from the caret, so the
|
||||||
// "editable last input" hint no longer applies.
|
// "editable last input" hint no longer applies.
|
||||||
clearEditHintIfPositive()
|
clearEditHintIfPositive()
|
||||||
|
state.editAvailable = false
|
||||||
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("clipboard insert length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,8 @@ final class KeyboardTextInserter {
|
|||||||
redoContextBefore = contextBeforeInput()
|
redoContextBefore = contextBeforeInput()
|
||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
|
state.editAvailable = false
|
||||||
|
EditableInputReferenceStore.clear()
|
||||||
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
OSGLog.keyboardExt.info("undo length=\(text.count, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +266,10 @@ final class KeyboardTextInserter {
|
|||||||
if state.cutAvailable != hasSelection {
|
if state.cutAvailable != hasSelection {
|
||||||
state.cutAvailable = hasSelection
|
state.cutAvailable = hasSelection
|
||||||
}
|
}
|
||||||
|
let editAvailable = editableReference() != nil
|
||||||
|
if state.editAvailable != editAvailable {
|
||||||
|
state.editAvailable = editAvailable
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func editableReference() -> EditableInputReference? {
|
func editableReference() -> EditableInputReference? {
|
||||||
@@ -288,15 +296,25 @@ final class KeyboardTextInserter {
|
|||||||
preceding.hasSuffix(reference.insertedText) else {
|
preceding.hasSuffix(reference.insertedText) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// The current extension instance owns an exact in-memory insertion
|
||||||
|
// record. Prefer it before the cross-process field fingerprint:
|
||||||
|
// UITextDocumentProxy can publish its updated surrounding context one
|
||||||
|
// callback after `insertText`, which otherwise makes a freshly shown
|
||||||
|
// Edit button disappear or reject its first tap.
|
||||||
|
if reference.matchesLiveInsertion(
|
||||||
|
extensionInstanceID: extensionInstanceID,
|
||||||
|
lastInsertedText: lastInsertedText,
|
||||||
|
contextBeforeInput: preceding
|
||||||
|
) {
|
||||||
|
return reference
|
||||||
|
}
|
||||||
guard reference.postInsertionFingerprint == nil
|
guard reference.postInsertionFingerprint == nil
|
||||||
|| reference.postInsertionFingerprint
|
|| reference.postInsertionFingerprint
|
||||||
== fieldContextProvider()?.deliveryFingerprint else {
|
== fieldContextProvider()?.deliveryFingerprint else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if reference.extensionInstanceID == extensionInstanceID,
|
// Rebuilt extension instances have no trusted in-memory insertion
|
||||||
lastInsertedText == reference.insertedText {
|
// record, so they continue to require the complete field fingerprint.
|
||||||
return reference
|
|
||||||
}
|
|
||||||
return reference.isFullyVerified(
|
return reference.isFullyVerified(
|
||||||
contextBeforeInput: preceding,
|
contextBeforeInput: preceding,
|
||||||
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
|
fieldFingerprint: fieldContextProvider()?.deliveryFingerprint
|
||||||
@@ -512,6 +530,14 @@ final class KeyboardTextInserter {
|
|||||||
extensionInstanceID: extensionInstanceID
|
extensionInstanceID: extensionInstanceID
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
state.editAvailable = true
|
||||||
|
state.assistantInsertionSucceeded = true
|
||||||
|
successPulseTask?.cancel()
|
||||||
|
successPulseTask = Task { @MainActor [weak state] in
|
||||||
|
try? await Task.sleep(for: .milliseconds(450))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
state?.assistantInsertionSucceeded = false
|
||||||
|
}
|
||||||
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
let hint = ExtL10n.string("keyboard.edit.hint.available")
|
||||||
editHintScheduler.show(
|
editHintScheduler.show(
|
||||||
message: hint,
|
message: hint,
|
||||||
@@ -527,6 +553,7 @@ final class KeyboardTextInserter {
|
|||||||
private func clearLastInsertion() {
|
private func clearLastInsertion() {
|
||||||
lastInsertedText = nil
|
lastInsertedText = nil
|
||||||
state.undoAvailable = false
|
state.undoAvailable = false
|
||||||
|
state.editAvailable = false
|
||||||
EditableInputReferenceStore.clear()
|
EditableInputReferenceStore.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ enum WhatsNewDemoDriver {
|
|||||||
"WhatsNewDemo start scenario=\(armed.scenario.rawValue) lang=\(armed.language.rawValue)",
|
"WhatsNewDemo start scenario=\(armed.scenario.rawValue) lang=\(armed.language.rawValue)",
|
||||||
category: "boot"
|
category: "boot"
|
||||||
)
|
)
|
||||||
|
AIKeyboardView.debugPreviewSkills = nil
|
||||||
polishDemoChrome(state)
|
polishDemoChrome(state)
|
||||||
switch armed.scenario {
|
switch armed.scenario {
|
||||||
case .edit:
|
case .edit:
|
||||||
@@ -66,7 +67,19 @@ enum WhatsNewDemoDriver {
|
|||||||
case .ai:
|
case .ai:
|
||||||
await runAI(state: state, host: host, language: armed.language)
|
await runAI(state: state, host: host, language: armed.language)
|
||||||
case .clipboard:
|
case .clipboard:
|
||||||
await runClipboard(state: state, host: host, language: armed.language)
|
await runClipboard(
|
||||||
|
state: state,
|
||||||
|
host: host,
|
||||||
|
language: armed.language,
|
||||||
|
showsSkills: false
|
||||||
|
)
|
||||||
|
case .clipboardSkills:
|
||||||
|
await runClipboard(
|
||||||
|
state: state,
|
||||||
|
host: host,
|
||||||
|
language: armed.language,
|
||||||
|
showsSkills: true
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if !Task.isCancelled {
|
if !Task.isCancelled {
|
||||||
WhatsNewDemoScenario.finishPlaying()
|
WhatsNewDemoScenario.finishPlaying()
|
||||||
@@ -215,7 +228,8 @@ enum WhatsNewDemoDriver {
|
|||||||
private static func runClipboard(
|
private static func runClipboard(
|
||||||
state: KeyboardState,
|
state: KeyboardState,
|
||||||
host: HostHooks,
|
host: HostHooks,
|
||||||
language: WhatsNewDemoScenario.Language
|
language: WhatsNewDemoScenario.Language,
|
||||||
|
showsSkills: Bool
|
||||||
) async {
|
) async {
|
||||||
let samples = language == .en
|
let samples = language == .en
|
||||||
? [
|
? [
|
||||||
@@ -273,6 +287,18 @@ enum WhatsNewDemoDriver {
|
|||||||
polishDemoChrome(state)
|
polishDemoChrome(state)
|
||||||
try? await sleep(0.2)
|
try? await sleep(0.2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard showsSkills else { return }
|
||||||
|
|
||||||
|
// The 1.8.0 clip continues from clipboard history into the real AI
|
||||||
|
// quick-skill row while the freshly copied item is still actionable.
|
||||||
|
clearClipboardChrome(state)
|
||||||
|
state.surface = .ai
|
||||||
|
state.aiServiceAvailable = true
|
||||||
|
AIKeyboardView.debugPreviewSkills = Array(AIClipboardSkillCatalog.catalog.prefix(5))
|
||||||
|
state.aiSession.enter()
|
||||||
|
polishDemoChrome(state)
|
||||||
|
try? await sleep(2.8)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Host helpers
|
// MARK: - Host helpers
|
||||||
|
|||||||
@@ -4,9 +4,9 @@
|
|||||||
// UIKit grid for the expanded Chinese candidate panel. UILabel cells +
|
// UIKit grid for the expanded Chinese candidate panel. UILabel cells +
|
||||||
// UICollectionView recycling stay far cheaper than hundreds of SwiftUI Buttons.
|
// UICollectionView recycling stay far cheaper than hundreds of SwiftUI Buttons.
|
||||||
|
|
||||||
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct CandidateExpandGridView: UIViewRepresentable {
|
struct CandidateExpandGridView: UIViewRepresentable {
|
||||||
var candidates: [TypingCandidate]
|
var candidates: [TypingCandidate]
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
//
|
//
|
||||||
// Switches between voice and typing surfaces driven by KeyboardState.surface.
|
// Switches between voice and typing surfaces driven by KeyboardState.surface.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
struct KeyboardSurfaceRoot: View {
|
struct KeyboardSurfaceRoot: View {
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@@ -37,7 +37,7 @@ struct KeyboardSurfaceRoot: View {
|
|||||||
Group {
|
Group {
|
||||||
switch state.surface {
|
switch state.surface {
|
||||||
case .voice:
|
case .voice:
|
||||||
KeyboardRootView(
|
AIKeyboardView(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
onInsert: wrappedInsert
|
onInsert: wrappedInsert
|
||||||
@@ -50,6 +50,8 @@ struct KeyboardSurfaceRoot: View {
|
|||||||
onDeleteBackward: wrappedDeleteBackward
|
onDeleteBackward: wrappedDeleteBackward
|
||||||
)
|
)
|
||||||
case .ai:
|
case .ai:
|
||||||
|
// Legacy persisted surface; controller canonicalizes it to
|
||||||
|
// `.voice`, but render the unified assistant defensively.
|
||||||
AIKeyboardView(
|
AIKeyboardView(
|
||||||
state: state,
|
state: state,
|
||||||
typing: typing,
|
typing: typing,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
// overlapping fingers commit in press order, delete repeats on down,
|
// overlapping fingers commit in press order, delete repeats on down,
|
||||||
// Shift holds while that finger owns it.
|
// Shift holds while that finger owns it.
|
||||||
|
|
||||||
|
import OSGKeyboardShared
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UIKit
|
import UIKit
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
struct TypingKeyTouchPad: UIViewRepresentable {
|
struct TypingKeyTouchPad: UIViewRepresentable {
|
||||||
var layout: TypingKeyLayout
|
var layout: TypingKeyLayout
|
||||||
@@ -174,8 +174,7 @@ final class TypingKeyTouchPadUIView: UIView {
|
|||||||
bottomRowMinY: 0
|
bottomRowMinY: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
/// Same trick as CursorDragPad: non-zero alpha so SwiftUI hosting does
|
/// Non-zero alpha keeps SwiftUI hosting from treating the pad as pass-through.
|
||||||
/// not treat the pad as pass-through.
|
|
||||||
private static let padTint = UIColor { traits in
|
private static let padTint = UIColor { traits in
|
||||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Typing surface: candidate bar + QWERTY / 123 / symbols.
|
// Typing surface: candidate bar + QWERTY / 123 / symbols.
|
||||||
// Top-leading control returns to the voice surface.
|
// Top-leading control returns to the voice surface.
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum TypingLayoutMetrics {
|
enum TypingLayoutMetrics {
|
||||||
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
|
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
// duplicate Localizable.strings variant groups into the main app target only.
|
// duplicate Localizable.strings variant groups into the main app target only.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
enum ExtL10n {
|
enum ExtL10n {
|
||||||
private static let table = "Keyboard"
|
private static let table = "Keyboard"
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Role-based typing haptics (no screen-position mapping). Intensity comes
|
// Role-based typing haptics (no screen-position mapping). Intensity comes
|
||||||
// from Settings → General → Haptics (off / light / strong).
|
// from Settings → General → Haptics (off / light / strong).
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Key roles drive distinct Taptic styles — closer to a real keyboard than
|
/// Key roles drive distinct Taptic styles — closer to a real keyboard than
|
||||||
/// a single buzz for every tap.
|
/// a single buzz for every tap.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// Plays the built-in iOS keyboard click sounds so the custom bottom-row
|
// Plays the built-in iOS keyboard click sounds so the custom bottom-row
|
||||||
// keys (space / return / delete) sound identical to the stock keyboard.
|
// keys (space / return / delete) sound identical to the stock keyboard.
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import AudioToolbox
|
import AudioToolbox
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// 让键盘扩展支持系统点击音。`UIDevice.playInputClick()` 只有在「某个
|
/// 让键盘扩展支持系统点击音。`UIDevice.playInputClick()` 只有在「某个
|
||||||
/// 可见的输入视图遵循本协议且返回 true」时才会发声。键盘扩展的根视图
|
/// 可见的输入视图遵循本协议且返回 true」时才会发声。键盘扩展的根视图
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user