feat(account): add managed credits and cloud gateway

Introduce optional Apple account-backed credits with scoped gateway access while preserving local and BYOK paths. Refresh assistant behavior, tests, privacy disclosures, docs, and the website for the 2.0 experience.
This commit is contained in:
Rocky
2026-08-20 11:43:21 +08:00
parent 0f9280bd00
commit 498f407585
301 changed files with 19221 additions and 10891 deletions
+10
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format ### Version format
The current source-of-truth version is **1.8.0 (build 79)**. 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 |
|-------|------|------| |-------|------|------|
+17
View File
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### 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 回答;上下文变化时保留回答,由用户明确插入或丢弃。 - **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 ### Changed
@@ -19,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed ### Removed
- **Cursor drag pads**: remove blank-area cursor sliding and its Settings toggle; legacy synced values remain decode-compatible. / **光标拖动区**:移除空白区域滑动光标及其设置开关;旧版同步值仍保持解码兼容。 - **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 以来的关键变化。 > **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 以来的关键变化。
+60
View File
@@ -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" : {
+4
View File
@@ -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>
+11
View File
@@ -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>
+13 -6
View File
@@ -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 {
@@ -41,6 +41,10 @@ struct OSGKeyboardApp: App {
AIClipboardSkillLayoutDemoView() AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") { } else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
AssistantKeyboardUITestHarness() 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") { } else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
FlowPiPDeviceUITestHarness() FlowPiPDeviceUITestHarness()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") { } else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
@@ -50,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()
@@ -141,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
@@ -154,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
+41 -3
View File
@@ -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>
+16 -12
View File
@@ -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 providers 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 devices 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 devices 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 apps 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 apps 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 devices 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 devices 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
+20 -1
View File
@@ -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,
+51 -25
View File
@@ -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?
@@ -1299,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 {
@@ -1364,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
) )
} }
} }
@@ -1389,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
@@ -1405,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"),
@@ -1425,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
@@ -1455,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
) )
@@ -1472,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,
@@ -1850,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
@@ -1886,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
@@ -2303,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",
@@ -2411,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
@@ -2463,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,
@@ -2547,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)
@@ -3012,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 {
@@ -3023,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
) )
} }
@@ -3059,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()
@@ -3079,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 }
+1 -1
View File
@@ -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] {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
@@ -7,8 +7,8 @@
// `--whats-new-lang=zh|en`. // `--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 config = ProviderConfig.shared
@@ -173,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 -1
View File
@@ -7,8 +7,8 @@
// `--whats-new-lang=zh|en`. // `--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 {
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
}
+1 -1
View File
@@ -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
@@ -1,7 +1,7 @@
#if DEBUG #if DEBUG
import OSGKeyboardShared
import SwiftUI import SwiftUI
import UIKit import UIKit
import OSGKeyboardShared
/// Deterministic host for simulator UI tests of the real assistant keyboard. /// Deterministic host for simulator UI tests of the real assistant keyboard.
/// It exercises gesture routing and closed UI states without requiring a /// It exercises gesture routing and closed UI states without requiring a
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 = "明天下午三点开会讨论方案"
+68 -27
View File
@@ -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 {
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+32 -2
View File
@@ -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 Groupbacked singletons. // touches App Groupbacked 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 {
@@ -220,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":
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+42 -22
View File
@@ -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 {
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -349,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
+54 -6
View File
@@ -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)
} }
+253 -6
View File
@@ -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 {
+1 -1
View File
@@ -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
+140 -5
View File
@@ -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.";
@@ -221,7 +232,7 @@
"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";
@@ -254,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";
@@ -271,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.";
@@ -670,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…";
+140 -5
View File
@@ -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" = "全程在手机本地,不用联网";
@@ -221,7 +232,7 @@
"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" = "语音";
@@ -254,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" = "强";
@@ -271,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 提交问题与建议。";
@@ -669,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" = "正在准备托管服务…";
+3 -3
View File
@@ -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 {
@@ -994,7 +994,7 @@ 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
@@ -110,7 +110,11 @@ 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.
@@ -121,7 +125,7 @@ final class AIKeyboardCoordinator {
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 {
@@ -136,6 +140,7 @@ final class AIKeyboardCoordinator {
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 {
@@ -154,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 {
@@ -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 {
@@ -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 {
@@ -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
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -4,8 +4,8 @@
// Unified assistant surface: tap for ordinary dictation, hold for AI, and use // Unified assistant surface: tap for ordinary dictation, hold for AI, and use
// the same idle layout for hotwords, clipboard skills, editing, and Send. // the same idle layout for hotwords, clipboard skills, editing, and Send.
import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI
struct AIKeyboardView: View { struct AIKeyboardView: View {
private enum Layout { private enum Layout {
@@ -3,8 +3,8 @@
// //
// Clipboard suggestion strip, enable-guide sheet, and history panel. // Clipboard suggestion strip, enable-guide sheet, and history panel.
import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI
// MARK: - Suggestion strip (Doubao-style) // MARK: - Suggestion strip (Doubao-style)
@@ -7,9 +7,9 @@
// a transient `UIEvent`. SwiftUI draws the shared native key chrome while a // a transient `UIEvent`. SwiftUI draws the shared native key chrome while a
// transparent `UIButton` owns touch delivery. // transparent `UIButton` owns touch delivery.
import OSGKeyboardShared
import SwiftUI import SwiftUI
import UIKit import UIKit
import OSGKeyboardShared
struct GlobeInputModeButton: UIViewRepresentable { struct GlobeInputModeButton: UIViewRepresentable {
@ObservedObject var state: KeyboardState @ObservedObject var state: KeyboardState
@@ -4,8 +4,8 @@
// Shared top-right input switcher used by both voice and typing surfaces. // Shared top-right input switcher used by both voice and typing surfaces.
// The same footprint is replaced by candidates while Chinese is composing. // The same footprint is replaced by candidates while Chinese is composing.
import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI
private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey { private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey {
static let defaultValue: Namespace.ID? = nil static let defaultValue: Namespace.ID? = nil
+1 -1
View File
@@ -1,8 +1,8 @@
// LastInputEditView.swift // LastInputEditView.swift
// OSGKeyboard · Keyboard Extension // OSGKeyboard · Keyboard Extension
import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI
struct LastInputEditView: View { struct LastInputEditView: View {
private enum Layout { private enum Layout {
@@ -3,8 +3,8 @@
// //
// Bottom-row action keys: repeating delete, space, and return. // Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import SwiftUI
// MARK: - Layout metrics // MARK: - Layout metrics
@@ -1,8 +1,8 @@
// CandidatePanelExpandTests.swift // CandidatePanelExpandTests.swift
// OSGKeyboard · Ext unit tests // OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
@MainActor @MainActor
final class CandidatePanelExpandTests: XCTestCase { final class CandidatePanelExpandTests: XCTestCase {
@@ -5,8 +5,8 @@
// and one keyboard presentation; clipboard history remains independently usable. // and one keyboard presentation; clipboard history remains independently usable.
import Combine import Combine
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
@MainActor @MainActor
final class ClipboardSuggestionLifecycleTests: XCTestCase { final class ClipboardSuggestionLifecycleTests: XCTestCase {
@@ -1,8 +1,8 @@
// EditHintSchedulerTests.swift // EditHintSchedulerTests.swift
// OSGKeyboard · Keyboard Extension Tests // OSGKeyboard · Keyboard Extension Tests
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
@MainActor @MainActor
final class EditHintSchedulerTests: XCTestCase { final class EditHintSchedulerTests: XCTestCase {
+1 -1
View File
@@ -3,8 +3,8 @@
// //
// Lexicon / accent / suggestion ranking for the English typing path. // Lexicon / accent / suggestion ranking for the English typing path.
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
final class EnglishTypingTests: XCTestCase { final class EnglishTypingTests: XCTestCase {
func testLexiconLoadsAndCompletesPrefix() { func testLexiconLoadsAndCompletesPrefix() {
@@ -1,8 +1,8 @@
// FinalChunkRecoveryTests.swift // FinalChunkRecoveryTests.swift
// OSGKeyboardExtTests // OSGKeyboardExtTests
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
final class FinalChunkRecoveryTests: XCTestCase { final class FinalChunkRecoveryTests: XCTestCase {
+1 -1
View File
@@ -3,8 +3,8 @@
// //
// Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion. // Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion.
import XCTest
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import XCTest
final class KeyHitTestingTests: XCTestCase { final class KeyHitTestingTests: XCTestCase {
private func makePairTargets( private func makePairTargets(

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