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:
@@ -63,6 +63,16 @@ jobs:
|
||||
run: ./Scripts/generate-xcodeproj.sh
|
||||
- name: Run PR test preset
|
||||
run: SKIP_GENERATE=1 ./Scripts/run-tests.sh pr
|
||||
- name: Run account and purchase UI regression
|
||||
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
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
+34
-1
@@ -11,6 +11,14 @@ disabled_rules:
|
||||
- cyclomatic_complexity
|
||||
- force_cast
|
||||
- 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:
|
||||
- empty_count
|
||||
@@ -18,7 +26,32 @@ opt_in_rules:
|
||||
- explicit_init
|
||||
- prefer_self_type_over_type_of_self
|
||||
- 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:
|
||||
- OSGKeyboard
|
||||
|
||||
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### 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 |
|
||||
|-------|------|------|
|
||||
|
||||
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Managed-cloud consent**: the first switch to OSG credits now explains which audio, text, context, and account data leaves the device and requires explicit agreement before enabling the service. / **托管云确认**:首次切换到 OSG 积分时会说明哪些音频、文字、上下文与账号数据会离开设备,并在用户明确同意后才启用服务。
|
||||
|
||||
### Changed
|
||||
- **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。
|
||||
|
||||
### Fixed
|
||||
- **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。
|
||||
- **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。
|
||||
- **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。
|
||||
|
||||
## [2.0.0] - 2026-08-19
|
||||
|
||||
### Added
|
||||
- **Optional OSG account**: add Sign in with Apple, managed credits, App Store credit packs, referrals, profile controls, and in-app account deletion while keeping local dictation and user-owned provider keys independent. / **可选 OSG 账号**:新增 Apple 登录、托管积分、App Store 积分包、邀请、资料管理与 App 内账号注销,同时保持本地听写和用户自备 API Key 独立可用。
|
||||
- **Safe AI insertion**: AI answers insert automatically only while the original input field and cursor context still match; otherwise the keyboard retains the answer for explicit insertion or discard. / **安全 AI 上屏**:仅当原输入框和光标上下文仍一致时自动插入 AI 回答;上下文变化时保留回答,由用户明确插入或丢弃。
|
||||
|
||||
### Changed
|
||||
@@ -19,6 +33,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Removed
|
||||
- **Cursor drag pads**: remove blank-area cursor sliding and its Settings toggle; legacy synced values remain decode-compatible. / **光标拖动区**:移除空白区域滑动光标及其设置开关;旧版同步值仍保持解码兼容。
|
||||
|
||||
### Fixed
|
||||
- **Account confirmation anchors**: sign-out and account-deletion confirmations now open from their selected action rows instead of the profile summary card. / **账号确认弹窗锚点**:退出登录与注销账号确认弹窗现在从对应操作行弹出,不再错误指向资料卡。
|
||||
|
||||
## [1.8.0] - 2026-08-14
|
||||
|
||||
> **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 以来的关键变化。
|
||||
|
||||
@@ -33,6 +33,66 @@
|
||||
"productID" : "ByRockyACoffee",
|
||||
"referenceName" : "给老刘买杯咖啡",
|
||||
"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" : {
|
||||
|
||||
@@ -102,6 +102,10 @@
|
||||
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
||||
<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>
|
||||
<key>OSGPrivateKeychainAccessGroup</key>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>SKIncludeConsumableInAppPurchaseHistory</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<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>
|
||||
<string>$(TeamIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
@@ -13,6 +23,7 @@
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
|
||||
<string>$(AppIdentifierPrefix)com.osgkeyboard.ios</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// OSGKeyboardApp.swift
|
||||
// OSGKeyboard · Main App
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OSGKeyboardApp: App {
|
||||
@@ -41,6 +41,10 @@ struct OSGKeyboardApp: App {
|
||||
AIClipboardSkillLayoutDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
|
||||
AssistantKeyboardUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--account-ui-test") {
|
||||
AccountCenterUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--managed-consent-ui-test") {
|
||||
ManagedCloudConsentUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
|
||||
FlowPiPDeviceUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||
@@ -50,6 +54,11 @@ struct OSGKeyboardApp: App {
|
||||
EditPagerUITestHarness()
|
||||
}
|
||||
.preferredColorScheme(appearance.colorScheme)
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--iap-review-screenshot") {
|
||||
ThemedRoot {
|
||||
IAPReviewScreenshotHarness()
|
||||
}
|
||||
.preferredColorScheme(.light)
|
||||
} else if AppGroup.isAvailable {
|
||||
ThemedRoot {
|
||||
MainAppRoot()
|
||||
@@ -141,8 +150,7 @@ struct OSGKeyboardApp: App {
|
||||
return WhatsNewDemoScenario(rawValue: raw)
|
||||
}
|
||||
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 nil
|
||||
@@ -154,8 +162,7 @@ struct OSGKeyboardApp: App {
|
||||
return WhatsNewDemoScenario.Language(rawValue: raw) ?? .zh
|
||||
}
|
||||
if let idx = args.firstIndex(of: "--whats-new-lang"),
|
||||
args.index(after: idx) < args.endIndex
|
||||
{
|
||||
args.index(after: idx) < args.endIndex {
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: args[args.index(after: idx)]
|
||||
) ?? .zh
|
||||
|
||||
@@ -9,9 +9,8 @@
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<!-- Voice recordings: processed on-device by default; uploaded to
|
||||
the user's configured ASR provider only when the cloud engine
|
||||
is explicitly enabled (opt-in with acknowledgment). -->
|
||||
<!-- Voice recordings: processed on-device by default; uploaded only
|
||||
after the user selects a configured or managed cloud engine. -->
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeAudioData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
@@ -35,6 +34,45 @@
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</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>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
|
||||
@@ -22,17 +22,18 @@
|
||||
<p class="lang"><a href="#zh">中文</a> · <a href="#top">English</a></p>
|
||||
<div id="top">
|
||||
<h1>OSGKeyboard Privacy Policy</h1>
|
||||
<p><strong>Last updated:</strong> August 14, 2026 · <em>v1.3</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><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. 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>
|
||||
<ul>
|
||||
<li><strong>Voice audio</strong> — captured only while you actively record. On a local engine, audio is transcribed on-device and raw audio is not uploaded. If you actively select a cloud engine in Settings, recordings are sent directly to the ASR provider you configure; that provider’s privacy policy applies. OSGKeyboard does not store or proxy audio on its own servers.</li>
|
||||
<li><strong>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>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>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 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 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 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>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>Optional clipboard history</strong> — off by default. When you turn it on, the keyboard may read text from the clipboard on this device or from Universal Clipboard; iOS does not provide a reliable way to distinguish those sources. Up to 15 accepted text items are stored only in the local App Group shared by this device’s host app and keyboard extension. Turning history off stops capture, turns off the suggestion strip, and keeps existing items. Reset Settings also keeps them; only the separate confirmed “Clear clipboard history” action removes them. There is no fixed expiry. Secure fields immediately hide clipboard UI and are not captured. Conservative filters reject common OTP shapes, private-key headers, JWTs, Bearer tokens, recognizable provider-key prefixes, and common Luhn-valid 16-digit card numbers, but cannot identify every password or secret. A rejected item can still be pasted through iOS; it is simply not added to history. Clipboard history is not automatically sent to AI. If you insert an item and then actively use polish, the inserted text may be included as context sent to the provider you configured.</li>
|
||||
<li><strong>Personal dictionary and typing learning</strong> — personal terms may sync through your private iCloud account when enabled. Chinese candidate frequencies and English suggestion choices remain in the local App Group and can be cleared separately without deleting the personal dictionary. OSGKeyboard does not upload this learning data.</li>
|
||||
@@ -55,13 +56,14 @@
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<p>Settings remain on your device until you delete the app or reset settings. With iCloud sync enabled, API keys use iCloud Keychain; eligible preferences, statistics, dictionary data, and voice history may sync through your private iCloud account.</p>
|
||||
<p><strong>Voice history</strong> — successful transcripts may be saved in the main app’s History tab (up to 300 entries). With iCloud settings sync enabled, history may also sync across your devices.</p>
|
||||
<p><strong>Clipboard history</strong> — stays in this device’s App Group, is capped at 15 entries, does not sync through iCloud, and has no fixed expiry. Turning the feature off or resetting settings keeps existing items. Use the separately confirmed clear action to delete them.</p>
|
||||
<p><strong>Typing learning and Mac models</strong> — Chinese and English learning data stays local and can be cleared from typing settings. Downloaded Mac speech models remain until you remove the model or delete its local files.</p>
|
||||
<p><strong>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>
|
||||
<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">
|
||||
<h1>OSGKeyboard 隐私政策</h1>
|
||||
<p><strong>更新日期:</strong>2026 年 8 月 14 日 · <em>v1.3</em></p>
|
||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。你也可以在设置中主动选择云端识别引擎。转写后,文字可能通过你配置的服务商用于润色、翻译或 AI Agent 功能。</p>
|
||||
<p><strong>更新日期:</strong>2026 年 8 月 19 日 · <em>v2.0</em></p>
|
||||
<p>OSGKeyboard 包含 iOS 自定义键盘与 macOS 菜单栏听写应用。iOS 默认使用 Apple 端侧语音能力;macOS 可使用下载到本机的 Qwen3-ASR 模型。本地能力和用户自备服务商能力无需登录 OSGKeyboard 账号。iOS 用户也可以选择 Apple 登录并使用托管积分,无需自行填写服务商凭证。本政策同时说明这两条可选路径。</p>
|
||||
|
||||
<h2>我们处理的数据</h2>
|
||||
<ul>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。若你在设置中主动选择云端引擎,录音会直接发送到你配置的识别服务商。OSGKeyboard 自身不存储或中转音频。</li>
|
||||
<li><strong>转写文字与光标上下文</strong> — 配置 LLM API Key 后,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。未填写 API Key 时直接插入原始识别结果。</li>
|
||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字,或你点选空闲建议后生成的提问,会发送到同一套已配置的 LLM 服务商以生成回答。若该服务商支持服务端联网搜索,可能为回答时效性问题检索公开网页结果。搜索词与检索片段由该服务商按其隐私政策处理;OSGKeyboard 不运营搜索索引,也不中转搜索流量。</li>
|
||||
<li><strong>语音音频</strong> — 仅在你主动录音时采集。使用本地引擎时,音频在设备端转写且原始录音不会上传。使用用户自备云端服务时,录音会直接发送到你配置的识别服务商;若你明确选择「使用积分」,录音会经 <code>account.osglab.com</code> 的 OSGKeyboard 托管网关发送到托管识别服务商。请求处理完成后,OSGKeyboard 不保留原始录音。</li>
|
||||
<li><strong>转写文字与光标上下文</strong> — 使用已配置的 LLM 或托管积分时,转写文字(非音频)可能发送润色,并可能包含光标附近少量文字以自然衔接。密码框不会采集,光标上下文不会写入日志或语音历史。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。</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 Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</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> — 默认关闭。开启后,键盘可能读取本机剪贴板或通用剪贴板中的文字;iOS 无法可靠区分两者来源。最多 15 条通过规则的文本仅保存在本机主 App 与键盘扩展共享的 App Group。关闭历史只会停止采集、关闭建议条并保留已有记录;重置设置同样不会清除,只有单独确认的「清空剪贴板历史」操作会删除。历史没有固定过期时间。进入安全输入框会立即隐藏剪贴板入口与正文,且不会采集。保守过滤会拒绝常见 OTP 形态、私钥头、JWT、Bearer Token、具有明确服务商前缀的密钥以及常见的通过 Luhn 校验的 16 位卡号,但无法识别所有密码或秘密。被拒绝的内容仍可通过 iOS 一次性粘贴,只是不进入历史。剪贴板历史不会自动发送给 AI;插入后若主动使用润色,已插入文字可能作为上下文发送给你配置的服务商。</li>
|
||||
<li><strong>个性词库与本地输入学习</strong> — 个性词条可在开启后经你的私有 iCloud 账户同步。中文候选频率与英文候选选择偏好保存在本机 App Group,可在输入设置中单独清除且不影响个性词库,OSGKeyboard 不会上传这些学习数据。</li>
|
||||
@@ -102,13 +105,14 @@
|
||||
</ul>
|
||||
|
||||
<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>
|
||||
<p>设置保留在设备上,直至卸载或重置。开启 iCloud 同步后,API 密钥走 iCloud 钥匙串;可同步的偏好、统计、词库与语音历史可能经私有 iCloud 账户同步。</p>
|
||||
<p><strong>语音历史</strong> — 成功转写可保存在主 App「历史」页(最多 300 条)。开启 iCloud 设置同步后,历史也可能在多设备间同步。</p>
|
||||
<p><strong>剪贴板历史</strong> — 仅保存在本机 App Group,上限 15 条,不经 iCloud 同步,也没有固定过期时间。关闭功能或重置设置会保留已有记录;需使用单独确认的清空操作才能删除。</p>
|
||||
<p><strong>输入学习与 Mac 模型</strong> — 中英文输入学习数据仅留本机,可在输入设置中单独清除。下载的 Mac 语音模型会保留到你移除模型或删除本地文件。</p>
|
||||
<p><strong>可选账号</strong> — 账号有效期间,账号服务会保留资料、积分账本、StoreKit 审计记录及有限的反滥用记录。注销会删除有效资料与凭证;为防止交易重放、欺诈或滥用,匿名账本、购买审计及有时限的反滥用记录可能在必要范围内继续保留。</p>
|
||||
|
||||
<h2>联系</h2>
|
||||
<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.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
enum AIAgentShortcutInstaller {
|
||||
@MainActor
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Consumes the keyboard's pending export-skill payload. Navigate opens a
|
||||
// map URL in the host. Other exports open the companion Shortcut.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
enum AIAgentShortcutRunner {
|
||||
@MainActor
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// host-return whitelist) is `UIOpenURLContext.options.sourceApplication` from a
|
||||
// scene delegate — SwiftUI's `.onOpenURL` does not expose it.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
/// Buffers launch/open URLs until the SwiftUI root registers a handler.
|
||||
///
|
||||
@@ -77,6 +77,7 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
handle(connectionOptions.urlContexts)
|
||||
handle(connectionOptions.userActivities)
|
||||
}
|
||||
|
||||
/// Warm open while the app is already running or suspended in memory.
|
||||
@@ -84,6 +85,11 @@ final class AppSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
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>) {
|
||||
// Extract Sendable primitives up front so we never hop a
|
||||
// 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 AVKit
|
||||
import CoreMedia
|
||||
import UIKit
|
||||
import OSGKeyboardHostSupport
|
||||
import UIKit
|
||||
|
||||
/// Why `startAndWait` could not prove an active PiP window.
|
||||
enum FlowPiPStartFailure: Equatable, Sendable {
|
||||
@@ -414,7 +414,7 @@ final class FlowPictureInPictureController: NSObject {
|
||||
let attrs: [String: Any] = [
|
||||
kCVPixelBufferCGImageCompatibilityKey as String: true,
|
||||
kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any],
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [:] as [String: Any]
|
||||
]
|
||||
let status = CVPixelBufferCreate(
|
||||
kCFAllocatorDefault,
|
||||
|
||||
@@ -5,13 +5,13 @@
|
||||
// `.playAndRecord` capture for the whole session, utterance gating for
|
||||
// ASR and cloud LLM polish, with App Group result delivery.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import Speech
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct FlowUtteranceStartToken: Equatable, Sendable {
|
||||
let generation: UInt64
|
||||
@@ -33,6 +33,13 @@ enum FlowUtteranceLifecyclePolicy {
|
||||
|
||||
@MainActor
|
||||
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 isStarting = false
|
||||
@Published private(set) var sessionExpiresAt: Date?
|
||||
@@ -1299,7 +1306,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
cancelAudioPrime(command)
|
||||
case .endAIConversation:
|
||||
if currentUtteranceMode == .aiQuestion,
|
||||
(isUtteranceProcessing || isUtteranceRecording) {
|
||||
isUtteranceProcessing || isUtteranceRecording {
|
||||
abortUtterance()
|
||||
}
|
||||
if let conversationID = command.aiConversationID {
|
||||
@@ -1364,15 +1371,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
let utteranceId = command.utteranceId
|
||||
let commandSeq = command.commandSeq
|
||||
let sessionId = command.sessionId
|
||||
let taskKind = command.aiTaskKind ?? .aiQuestion
|
||||
let thinkingEnabled = command.aiThinkingEnabled ?? true
|
||||
Task { @MainActor [weak self] in
|
||||
await self?.answerPrefilledAIQuestion(
|
||||
question: question,
|
||||
conversationID: conversationID,
|
||||
request: PrefilledAIRequest(
|
||||
question: question,
|
||||
conversationID: conversationID,
|
||||
taskKind: taskKind,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
),
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
commandSeq: commandSeq,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
commandSeq: commandSeq
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1389,12 +1400,10 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
/// Skip ASR and answer a prefilled AI hint / typed question.
|
||||
private func answerPrefilledAIQuestion(
|
||||
question: String,
|
||||
conversationID: UUID?,
|
||||
request: PrefilledAIRequest,
|
||||
sessionId: UUID,
|
||||
utteranceId: UUID,
|
||||
commandSeq: Int64,
|
||||
thinkingEnabled: Bool
|
||||
commandSeq: Int64
|
||||
) async {
|
||||
// Hint-card / prefilled questions never go through `finalizeUtterance`,
|
||||
// so this path must drop the processing gate itself. Leaving it set
|
||||
@@ -1405,7 +1414,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
utteranceId: utteranceId
|
||||
)
|
||||
}
|
||||
guard let conversationID else {
|
||||
guard let conversationID = request.conversationID else {
|
||||
claimAndStoreTerminal(utteranceId: utteranceId) {
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||
@@ -1425,11 +1434,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
let service = try AIQuestionService.configured(
|
||||
store: pipelineStore,
|
||||
conversations: aiConversations,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
taskKind: request.taskKind,
|
||||
thinkingEnabled: request.thinkingEnabled
|
||||
)
|
||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||
let answer = try await service.answer(
|
||||
question: question,
|
||||
question: request.question,
|
||||
conversationID: conversationID,
|
||||
targetLocaleID: pipelineStore.translationTargetLocaleId
|
||||
) { [weak self] partial in
|
||||
@@ -1455,7 +1465,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
force: true
|
||||
)
|
||||
await service.commitSuccessfulTurn(
|
||||
question: question,
|
||||
question: request.question,
|
||||
answer: answer,
|
||||
conversationID: conversationID
|
||||
)
|
||||
@@ -1472,7 +1482,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
} catch {
|
||||
claimAndStoreTerminal(utteranceId: utteranceId) {
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||
Self.aiQuestionFailureMessage(for: error),
|
||||
kind: .generic,
|
||||
sessionId: sessionId,
|
||||
utteranceId: utteranceId,
|
||||
@@ -1850,9 +1860,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
let locale = SpeechLocaleResolver.resolve(localeId)
|
||||
let stream = capture.beginUtterance()
|
||||
let cloudService = asr as? CloudASRService
|
||||
let useStreaming =
|
||||
store.engineMode == "cloud"
|
||||
&& CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
||||
&& (
|
||||
cloudService?.supportsUtteranceStreaming
|
||||
?? CloudASRModelCatalog.supportsTrueStreamingASR(for: store.asrProviderId)
|
||||
)
|
||||
let pipeline: ChunkedUtterancePipeline?
|
||||
if useStreaming {
|
||||
chunkedPipeline = nil
|
||||
@@ -1886,7 +1900,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
"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
|
||||
let outcome: ChunkedUtterancePipelineOutcome
|
||||
@@ -2303,7 +2317,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
|
||||
let wantsBatchFallback = UtteranceBatchFallbackPolicy.shouldRunBatchFallback(
|
||||
stitchedFinal: lastFinal,
|
||||
partialSnapshot: bestPartialSnapshot
|
||||
partialSnapshot: bestPartialSnapshot,
|
||||
recognitionFailed: asrFailureMessage != nil
|
||||
)
|
||||
FlowTrace.pipeline(
|
||||
"batchFallback.decision",
|
||||
@@ -2411,7 +2426,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
do {
|
||||
let service = try AIQuestionService.configured(
|
||||
store: pipelineStore,
|
||||
conversations: aiConversations
|
||||
conversations: aiConversations,
|
||||
taskKind: .aiQuestion
|
||||
)
|
||||
aiAnswerStreamThrottle = AIAnswerStreamThrottle()
|
||||
let publishUtteranceId = finalizeUtteranceId
|
||||
@@ -2463,7 +2479,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
} catch {
|
||||
claimAndStoreTerminal(utteranceId: finalizeUtteranceId) {
|
||||
storeFinalizedError(
|
||||
AppL10n.string("flow.error.aiQuestionFailed"),
|
||||
Self.aiQuestionFailureMessage(for: error),
|
||||
kind: .generic,
|
||||
sessionId: finalizeSessionId,
|
||||
utteranceId: finalizeUtteranceId,
|
||||
@@ -2547,6 +2563,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
mode: polishMode,
|
||||
systemPrompt: instructionPrompt?.system,
|
||||
providerIdOverride: pipelineStore.polishProviderIdOverride,
|
||||
taskKind: isEditLastInput ? .editLastInput : nil,
|
||||
context: isInstructionMode ? nil : polishContext,
|
||||
timeoutLimit: processingDeadlineAt.map {
|
||||
max(0.1, $0 - Date().timeIntervalSince1970)
|
||||
@@ -3012,6 +3029,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
mode: PolishingService.PolishMode,
|
||||
systemPrompt: String? = nil,
|
||||
providerIdOverride: String?,
|
||||
taskKind: ManagedGatewayTaskKind? = nil,
|
||||
context: PolishContext?,
|
||||
timeoutLimit: TimeInterval? = nil
|
||||
) async throws -> PolishingService.PolishOutcome {
|
||||
@@ -3023,6 +3041,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
taskKind: taskKind,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
@@ -3059,7 +3078,7 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard let self else { break }
|
||||
if self.isActive,
|
||||
!self.capture.engineIsLive,
|
||||
(self.isUtteranceRecording || self.isUtteranceProcessing) {
|
||||
self.isUtteranceRecording || self.isUtteranceProcessing {
|
||||
await self.reactivateCaptureIfNeeded()
|
||||
}
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
@@ -3079,6 +3098,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
+ "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) {
|
||||
let signature = "\(reason)|\(command.action.rawValue)|\(command.commandSeq)|\(command.sessionId.uuidString)|\(command.utteranceId.uuidString)|\(detail)"
|
||||
guard signature != lastIgnoredCommandSignature else { return }
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Opens a whitelisted host-app URL after a cold-start Flow handoff.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
enum HostReturnService {
|
||||
/// 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"),
|
||||
licenseText: qwenModelApacheText,
|
||||
platforms: [.macOS]
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
static func entries(for platform: Platform) -> [Entry] {
|
||||
|
||||
@@ -59,7 +59,7 @@ enum ReleaseNotesStore {
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "v", value: version),
|
||||
URLQueryItem(name: "lang", value: queryLanguage(for: language)),
|
||||
URLQueryItem(name: "theme", value: queryTheme(for: colorScheme)),
|
||||
URLQueryItem(name: "theme", value: queryTheme(for: colorScheme))
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
// observable outcome to onboarding and Settings. The keyboard extension only
|
||||
// reads readiness and opens the already-built data.
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -14,7 +14,7 @@ enum ASRLocaleLabels {
|
||||
"zh-Hant": "locale.zh-Hant",
|
||||
"en-US": "locale.en-US",
|
||||
"ja-JP": "locale.ja-JP",
|
||||
"ko-KR": "locale.ko-KR",
|
||||
"ko-KR": "locale.ko-KR"
|
||||
]
|
||||
|
||||
static func displayName(for localeId: String, language: AppUILanguage) -> String {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// the in-app UI language override (not only the system language).
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AppL10n {
|
||||
static func string(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AIClipboardSkillLayoutDemoView: View {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@@ -173,7 +173,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
descriptionKey: "skills.translate.description",
|
||||
kind: .transform,
|
||||
isDefault: false
|
||||
),
|
||||
)
|
||||
]
|
||||
extras = Array(extras.prefix(count - catalog.count))
|
||||
return catalog + extras
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AIKeyboardDemoView: View {
|
||||
private enum Scene: Equatable {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Editable fields for the three OpenAI-compatible config values:
|
||||
// Base URL, API Key, Model.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct APISettingsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//
|
||||
// Cloud ASR credentials — independent from the polish LLM card.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct ASRSettingsCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// AccountAppleAuthorizationButton.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Sign in with Apple presentation and nonce handling shared by sign-in and
|
||||
// deletion reauthentication. Tokens are passed directly to the auth boundary.
|
||||
|
||||
import AuthenticationServices
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AccountAppleAuthorizationPurpose {
|
||||
case signIn
|
||||
case deleteAccount
|
||||
}
|
||||
|
||||
struct AccountAppleAuthorizationButton: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
let purpose: AccountAppleAuthorizationPurpose
|
||||
var onDeleted: () -> Void = {}
|
||||
|
||||
@State private var rawNonce: String?
|
||||
|
||||
var body: some View {
|
||||
SignInWithAppleButton(
|
||||
purpose == .signIn ? .signIn : .continue,
|
||||
onRequest: prepareRequest,
|
||||
onCompletion: completeAuthorization
|
||||
)
|
||||
.signInWithAppleButtonStyle(colorScheme == .dark ? .white : .black)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
)
|
||||
}
|
||||
|
||||
private func prepareRequest(_ request: ASAuthorizationAppleIDRequest) {
|
||||
guard let nonce = AccountAppleNonce.make() else {
|
||||
rawNonce = nil
|
||||
request.nonce = nil
|
||||
return
|
||||
}
|
||||
rawNonce = nonce.rawValue
|
||||
request.requestedScopes = purpose == .signIn ? [.fullName] : []
|
||||
request.nonce = nonce.sha256Hex
|
||||
}
|
||||
|
||||
private func completeAuthorization(_ result: Result<ASAuthorization, Error>) {
|
||||
guard let rawNonce else {
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=nonce-unavailable",
|
||||
category: "account"
|
||||
)
|
||||
coordinator.recordAppleAuthorizationFailure()
|
||||
return
|
||||
}
|
||||
self.rawNonce = nil
|
||||
|
||||
switch result {
|
||||
case let .success(authorization):
|
||||
guard let payload = AccountAppleAuthorizationPayload.make(
|
||||
from: authorization,
|
||||
rawNonce: rawNonce
|
||||
) else {
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=credential-payload",
|
||||
category: "account"
|
||||
)
|
||||
coordinator.recordAppleAuthorizationFailure()
|
||||
return
|
||||
}
|
||||
Task {
|
||||
switch purpose {
|
||||
case .signIn:
|
||||
await coordinator.signIn(with: payload)
|
||||
if coordinator.isSignedIn {
|
||||
ProviderConfig.shared.reloadFromPersistedStorage()
|
||||
}
|
||||
case .deleteAccount:
|
||||
await coordinator.deleteAccount(with: payload)
|
||||
if !coordinator.isSignedIn {
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
let diagnostic = error as NSError
|
||||
OSGDiag.log(
|
||||
"appleAuthorization failed stage=system "
|
||||
+ "domain=\(diagnostic.domain) code=\(diagnostic.code)",
|
||||
category: "account"
|
||||
)
|
||||
if (error as? ASAuthorizationError)?.code != .canceled {
|
||||
coordinator.recordAppleAuthorizationFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountAppleAuthorizationPayload {
|
||||
static func make(
|
||||
from authorization: ASAuthorization,
|
||||
rawNonce: String
|
||||
) -> AppleAuthorizationPayload? {
|
||||
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
|
||||
let identityTokenData = credential.identityToken,
|
||||
let authorizationCodeData = credential.authorizationCode,
|
||||
let identityToken = String(data: identityTokenData, encoding: .utf8),
|
||||
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
|
||||
!identityToken.isEmpty,
|
||||
!authorizationCode.isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleAuthorizationPayload(
|
||||
identityToken: identityToken,
|
||||
authorizationCode: authorizationCode,
|
||||
nonce: rawNonce,
|
||||
displayName: displayName(from: credential.fullName)
|
||||
)
|
||||
}
|
||||
|
||||
private static func displayName(from components: PersonNameComponents?) -> String? {
|
||||
guard let components else { return nil }
|
||||
let value = PersonNameComponentsFormatter.localizedString(
|
||||
from: components,
|
||||
style: .default,
|
||||
options: []
|
||||
)
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountAppleNonce {
|
||||
static func make() -> AppleSignInNonce? {
|
||||
// Do not fall back to a merely unique identifier if the system RNG is
|
||||
// unavailable. The completion path rejects authorization without a
|
||||
// cryptographically random raw nonce.
|
||||
return try? AppleSignInNonceGenerator().makeNonce()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// AccountCenterUITestHarness.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Deterministic account and StoreKit UI integration harness. It exercises the
|
||||
// production coordinator and views while keeping Apple and backend services
|
||||
// outside XCUITest.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AccountCenterUITestHarness: View {
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
init() {
|
||||
let service = AccountCenterUITestService()
|
||||
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
|
||||
_coordinator = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
creditStore: store
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
NavigationStack {
|
||||
AccountCenterView()
|
||||
.environmentObject(coordinator)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await coordinator.restoreIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct ManagedCloudConsentUITestHarness: View {
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@StateObject private var coordinator: AccountSessionCoordinator
|
||||
|
||||
init() {
|
||||
let service = AccountCenterUITestService()
|
||||
let configuration = ProviderConfig.shared
|
||||
configuration.hasAcknowledgedCloudSharing = false
|
||||
configuration.credentialSource = .byok
|
||||
_coordinator = StateObject(
|
||||
wrappedValue: AccountSessionCoordinator(
|
||||
dependencies: AccountDependencies(
|
||||
sessionService: service,
|
||||
centerService: service
|
||||
),
|
||||
creditStore: AccountCenterUITestCreditStore(
|
||||
accountID: service.account.accountID
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ThemedRoot {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
EnginePickerSection(config: config)
|
||||
.environmentObject(coordinator)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("settings.aiService.title")
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await coordinator.restoreIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor AccountCenterUITestService: AccountSessionServicing, AccountCenterServicing {
|
||||
nonisolated let account = AccountSession(
|
||||
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!,
|
||||
createdAtEpochSeconds: 1_700_000_000,
|
||||
displayName: "UI Test Account"
|
||||
)
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
account
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
_ = payload
|
||||
return account
|
||||
}
|
||||
|
||||
func signOut() async throws {}
|
||||
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||
_ = payload
|
||||
}
|
||||
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||
AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: AccountCreditSummary(balance: 1_500, usedCredits: 500),
|
||||
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||
referrals: []
|
||||
)
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||
AccountSession(
|
||||
accountID: account.accountID,
|
||||
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
"UITestInvite_1234567890"
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
_ = code
|
||||
}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
[
|
||||
AccountCreditProduct(productID: "500tks", credits: 500),
|
||||
AccountCreditProduct(productID: "1500tks", credits: 1_500),
|
||||
AccountCreditProduct(productID: "3000tks", credits: 3_000)
|
||||
]
|
||||
}
|
||||
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||
_ = signedTransaction
|
||||
return AccountCreditPurchase(
|
||||
transactionID: "2000000000200",
|
||||
productID: "500tks",
|
||||
creditsGranted: 500,
|
||||
balanceAfter: 2_000,
|
||||
replayed: false
|
||||
)
|
||||
}
|
||||
|
||||
func loadCreditPurchaseHistory(
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||
_ = limit
|
||||
_ = cursor
|
||||
return AccountCreditPurchaseHistoryPage(
|
||||
items: [
|
||||
AccountCreditPurchaseRecord(
|
||||
transactionID: "2000000000199",
|
||||
productID: "1500tks",
|
||||
creditsGranted: 1_500,
|
||||
balanceAfter: 1_500,
|
||||
purchasedAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
status: .credited
|
||||
)
|
||||
],
|
||||
nextCursor: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AccountCenterUITestCreditStore: AccountCreditStore {
|
||||
private let accountID: UUID
|
||||
private var transactions: [AccountStoreVerification]
|
||||
|
||||
init(accountID: UUID) {
|
||||
self.accountID = accountID
|
||||
transactions = [
|
||||
.verified(
|
||||
AccountStoreTransaction(
|
||||
id: 2_000_000_000_199,
|
||||
productID: "1500tks",
|
||||
appAccountToken: accountID,
|
||||
purchaseDate: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
signedTransaction: String(repeating: "h", count: 100),
|
||||
finishOperation: {}
|
||||
)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||
let price: String
|
||||
switch productID {
|
||||
case "500tks":
|
||||
price = "$0.99"
|
||||
case "1500tks":
|
||||
price = "$1.99"
|
||||
case "3000tks":
|
||||
price = "$2.99"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return AccountStoreProduct(id: productID, displayPrice: price)
|
||||
}
|
||||
|
||||
func purchase(
|
||||
productID: String,
|
||||
accountID: UUID
|
||||
) async throws -> AccountStorePurchaseOutcome {
|
||||
guard productID == "500tks", accountID == self.accountID else {
|
||||
return .success(.unverified)
|
||||
}
|
||||
let transaction = AccountStoreTransaction(
|
||||
id: 2_000_000_000_200,
|
||||
productID: productID,
|
||||
appAccountToken: accountID,
|
||||
purchaseDate: Date(),
|
||||
signedTransaction: String(repeating: "p", count: 100),
|
||||
finishOperation: {}
|
||||
)
|
||||
let verification = AccountStoreVerification.verified(transaction)
|
||||
transactions.append(verification)
|
||||
return .success(verification)
|
||||
}
|
||||
|
||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
let values = transactions
|
||||
return AsyncStream { continuation in
|
||||
values.forEach { continuation.yield($0) }
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,962 @@
|
||||
// AccountCenterView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Optional account center. Signing in unlocks managed credits and referrals;
|
||||
// local transcription and user-owned provider keys remain independent.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct AccountCenterView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@EnvironmentObject private var coordinator: AccountSessionCoordinator
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
|
||||
@State private var showSignOutConfirmation = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var showDeleteReauthentication = false
|
||||
@State private var showProfileEditor = false
|
||||
@State private var displayNameDraft = ""
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
palette.background.ignoresSafeArea()
|
||||
sessionContent
|
||||
}
|
||||
.navigationTitle("account.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task(id: coordinator.accountID) {
|
||||
guard let accountID = coordinator.accountID else {
|
||||
coordinator.creditPurchases.reset()
|
||||
return
|
||||
}
|
||||
await coordinator.refreshAccountData()
|
||||
await coordinator.creditPurchases.prepare(accountID: accountID)
|
||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||
displayNameDraft = session.displayName ?? ""
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
coordinator.creditPurchases.dismissSuccessMessage()
|
||||
}
|
||||
.alert(
|
||||
"account.error.title",
|
||||
isPresented: operationErrorBinding
|
||||
) {
|
||||
Button("common.done") {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = coordinator.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
"account.profile.editTitle",
|
||||
isPresented: $showProfileEditor
|
||||
) {
|
||||
TextField("account.profile.nickname", text: $displayNameDraft)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("account.profile.save") {
|
||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||
saveDisplayName(session: session)
|
||||
}
|
||||
}
|
||||
.disabled(!canSaveCurrentDisplayName)
|
||||
} message: {
|
||||
Text("account.profile.editMessage")
|
||||
}
|
||||
.sheet(isPresented: $showDeleteReauthentication) {
|
||||
deleteReauthenticationSheet
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var sessionContent: some View {
|
||||
switch coordinator.sessionPhase {
|
||||
case .restoring:
|
||||
AccountStateCard(
|
||||
systemImage: "person.crop.circle",
|
||||
titleKey: "account.loading.session",
|
||||
showsProgress: true
|
||||
)
|
||||
case .signedOut:
|
||||
signedOutContent
|
||||
case let .signedIn(session):
|
||||
signedInContent(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
private var signedOutContent: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
VStack(spacing: Spacing.lg) {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.font(.system(.largeTitle, design: .rounded, weight: .semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Text("account.signedOut.title")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("account.signedOut.body")
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
AccountAppleAuthorizationButton(purpose: .signIn)
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.operation == .signingIn {
|
||||
ProgressView("account.signIn.loading")
|
||||
.tint(palette.accent)
|
||||
}
|
||||
|
||||
if coordinator.pendingReferralCode != nil {
|
||||
Label("account.referral.pendingAfterSignIn", systemImage: "link")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Text("account.signedOut.localUnaffected")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func signedInContent(session: AccountSession) -> some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
accountSummarySection(session: session)
|
||||
accountRewardsContent(session: session)
|
||||
securitySection
|
||||
}
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.refreshable {
|
||||
await coordinator.refreshAccountData(force: true)
|
||||
}
|
||||
.accessibilityIdentifier("account.center.signedIn")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func accountRewardsContent(session: AccountSession) -> some View {
|
||||
switch coordinator.snapshotPhase {
|
||||
case .idle, .loading:
|
||||
AccountStateCard(
|
||||
systemImage: "arrow.triangle.2.circlepath",
|
||||
titleKey: "account.loading.center",
|
||||
showsProgress: true
|
||||
)
|
||||
case let .failed(messageKey):
|
||||
AccountStateCard(
|
||||
systemImage: "exclamationmark.triangle",
|
||||
titleKey: messageKey,
|
||||
actionKey: "account.retry"
|
||||
) {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
case .loaded:
|
||||
CardSection("account.storekit.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditPurchaseSection(
|
||||
manager: coordinator.creditPurchases,
|
||||
accountID: session.accountID,
|
||||
onGranted: {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
)
|
||||
purchaseHistoryLink(accountID: session.accountID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseHistoryLink(accountID: UUID) -> some View {
|
||||
NavigationLink {
|
||||
AccountPurchaseHistoryView(
|
||||
manager: coordinator.creditPurchases,
|
||||
accountID: accountID
|
||||
)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 24)
|
||||
.accessibilityHidden(true)
|
||||
Text("account.purchaseHistory.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.surfaceCard()
|
||||
.accessibilityIdentifier("account.purchaseHistory.link")
|
||||
}
|
||||
|
||||
private func accountSummarySection(session: AccountSession) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
|
||||
return HStack(spacing: Spacing.md) {
|
||||
AccountAvatarView(
|
||||
accountID: session.accountID,
|
||||
displayName: session.displayName,
|
||||
size: 52
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(
|
||||
session.displayName
|
||||
?? AppL10n.string(
|
||||
"account.profile.fallbackName",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(verbatim: "ID · \(session.accountID.uuidString.suffix(8))")
|
||||
.font(TypeStyle.caption2.monospaced())
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Button {
|
||||
displayNameDraft = session.displayName ?? ""
|
||||
showProfileEditor = true
|
||||
} label: {
|
||||
Group {
|
||||
if coordinator.operation == .updatingProfile {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "square.and.pencil")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.accessibilityLabel("account.profile.editTitle")
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.surface, in: shape)
|
||||
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
|
||||
.accessibilityIdentifier("account.summary")
|
||||
}
|
||||
|
||||
private var canSaveCurrentDisplayName: Bool {
|
||||
guard case let .signedIn(session) = coordinator.sessionPhase else { return false }
|
||||
return canSaveDisplayName(session: session)
|
||||
}
|
||||
|
||||
private func canSaveDisplayName(session: AccountSession) -> Bool {
|
||||
let value = displayNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !value.isEmpty
|
||||
&& value.count <= 64
|
||||
&& value != session.displayName
|
||||
&& coordinator.operation == nil
|
||||
}
|
||||
|
||||
private func saveDisplayName(session: AccountSession) {
|
||||
guard canSaveDisplayName(session: session) else { return }
|
||||
let value = displayNameDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
Task {
|
||||
if await coordinator.updateDisplayName(value) {
|
||||
displayNameDraft = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var securitySection: some View {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
showSignOutConfirmation = true
|
||||
} label: {
|
||||
AccountActionRow(
|
||||
titleKey: "account.signOut.action",
|
||||
systemImage: "rectangle.portrait.and.arrow.right",
|
||||
color: palette.textPrimary,
|
||||
showsProgress: coordinator.operation == .signingOut
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.confirmationDialog(
|
||||
"account.signOut.confirmTitle",
|
||||
isPresented: $showSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("account.signOut.action", role: .destructive) {
|
||||
Task {
|
||||
await coordinator.signOut()
|
||||
if !coordinator.isSignedIn {
|
||||
config.credentialSource = .byok
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("account.signOut.confirmMessage")
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
AccountActionRow(
|
||||
titleKey: "account.delete.action",
|
||||
systemImage: "trash",
|
||||
color: palette.danger,
|
||||
showsProgress: coordinator.operation == .deletingAccount
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.confirmationDialog(
|
||||
"account.delete.confirmTitle",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("account.delete.continue", role: .destructive) {
|
||||
showDeleteReauthentication = true
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("account.delete.confirmMessage")
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private var deleteReauthenticationSheet: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: Spacing.lg) {
|
||||
Image(systemName: "person.crop.circle.badge.xmark")
|
||||
.font(.system(.largeTitle, design: .rounded, weight: .semibold))
|
||||
.foregroundStyle(palette.danger)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("account.delete.reauthenticateBody")
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
AccountAppleAuthorizationButton(
|
||||
purpose: .deleteAccount,
|
||||
onDeleted: {
|
||||
showDeleteReauthentication = false
|
||||
config.credentialSource = .byok
|
||||
}
|
||||
)
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.operation == .deletingAccount {
|
||||
ProgressView("account.delete.loading")
|
||||
.tint(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.delete.reauthenticateTitle")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") {
|
||||
showDeleteReauthentication = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
|
||||
private var operationErrorBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { coordinator.operationErrorKey != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented {
|
||||
coordinator.dismissOperationError()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountAvatarView: View {
|
||||
let accountID: UUID
|
||||
let displayName: String?
|
||||
let size: CGFloat
|
||||
|
||||
private static let gradients: [[Color]] = [
|
||||
[.indigo, .blue],
|
||||
[.purple, .pink],
|
||||
[.teal, .cyan],
|
||||
[.orange, .red],
|
||||
[.mint, .green]
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Text(initials)
|
||||
.font(.system(size: size * 0.34, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: size, height: size)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: Self.gradients[gradientIndex],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
),
|
||||
in: Circle()
|
||||
)
|
||||
.overlay(Circle().stroke(.white.opacity(0.2), lineWidth: 0.5))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
private var initials: String {
|
||||
let parts = (displayName ?? "")
|
||||
.split(whereSeparator: \.isWhitespace)
|
||||
.prefix(2)
|
||||
let value = parts.compactMap(\.first).map(String.init).joined().uppercased()
|
||||
return value.isEmpty ? "O" : value
|
||||
}
|
||||
|
||||
private var gradientIndex: Int {
|
||||
let hash = accountID.uuidString.unicodeScalars.reduce(UInt32(2_166_136_261)) {
|
||||
($0 ^ $1.value) &* 16_777_619
|
||||
}
|
||||
return Int(hash % UInt32(Self.gradients.count))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountCreditsSection: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
let snapshot: AccountCenterSnapshot
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(
|
||||
snapshot.credits.balance,
|
||||
format: .number.grouping(.automatic)
|
||||
)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.md)
|
||||
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
Text("account.credits.used")
|
||||
Text(
|
||||
snapshot.credits.usedCredits,
|
||||
format: .number.grouping(.automatic)
|
||||
)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
AccountCreditProgress(
|
||||
remaining: snapshot.credits.balance,
|
||||
used: snapshot.credits.usedCredits,
|
||||
showsLabels: false
|
||||
)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountCreditPurchaseSection: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||
|
||||
let accountID: UUID
|
||||
let onGranted: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
if !manager.options.isEmpty {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(manager.options) { option in
|
||||
purchaseOption(option)
|
||||
if option.id != manager.options.last?.id {
|
||||
Divider().background(palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
} else if manager.state == .loading {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.storekit.loading")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
stateMessage
|
||||
}
|
||||
.onChange(of: manager.lastGrantedBalance) { previous, current in
|
||||
guard current != nil, current != previous else { return }
|
||||
onGranted()
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseOption(_ option: AccountCreditPurchaseOption) -> some View {
|
||||
Button {
|
||||
Task {
|
||||
_ = await manager.purchase(
|
||||
productID: option.productID,
|
||||
accountID: accountID
|
||||
)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||
Text(option.credits, format: .number.grouping(.automatic))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
Text(option.displayPrice)
|
||||
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
Group {
|
||||
if purchasingProductID == option.productID {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(.white)
|
||||
} else {
|
||||
Text("account.storekit.purchase")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minWidth: 54, minHeight: 34)
|
||||
.background(
|
||||
palette.accent,
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, minHeight: 64)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(purchasingProductID != nil)
|
||||
.accessibilityIdentifier("account.purchase.\(option.productID)")
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text(option.credits, format: .number)) \(Text("account.storekit.credits")) \(Text(option.displayPrice))"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var purchasingProductID: String? {
|
||||
guard case .purchasing(let productID) = manager.state else { return nil }
|
||||
return productID
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var stateMessage: some View {
|
||||
Group {
|
||||
switch manager.state {
|
||||
case .pending:
|
||||
Label("account.storekit.pending", systemImage: "clock")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
case .succeeded(let credits):
|
||||
Label {
|
||||
Text(
|
||||
String(
|
||||
format: NSLocalizedString(
|
||||
"account.storekit.succeeded",
|
||||
comment: ""
|
||||
),
|
||||
credits
|
||||
)
|
||||
)
|
||||
} icon: {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
}
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.success)
|
||||
case .failed(let messageKey):
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
LocalizedStringKey(messageKey),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.danger)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button("account.retry") {
|
||||
Task { await manager.prepare(accountID: accountID) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("account.purchase.status")
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountCreditProgress: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let remaining: Int64
|
||||
let used: Int64
|
||||
let showsLabels: Bool
|
||||
|
||||
init(remaining: Int64, used: Int64, showsLabels: Bool = true) {
|
||||
self.remaining = remaining
|
||||
self.used = used
|
||||
self.showsLabels = showsLabels
|
||||
}
|
||||
|
||||
private var usedFraction: Double {
|
||||
fraction(for: used)
|
||||
}
|
||||
|
||||
private var remainingFraction: Double {
|
||||
fraction(for: remaining)
|
||||
}
|
||||
|
||||
private func fraction(for value: Int64) -> Double {
|
||||
let available = Double(max(remaining, 0))
|
||||
let consumed = Double(max(used, 0))
|
||||
let total = available + consumed
|
||||
return total > 0 ? Double(max(value, 0)) / total : 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
GeometryReader { proxy in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(palette.surfaceElevated)
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(palette.success)
|
||||
.frame(width: proxy.size.width * remainingFraction)
|
||||
Rectangle()
|
||||
.fill(palette.textTertiary.opacity(0.55))
|
||||
.frame(width: proxy.size.width * usedFraction)
|
||||
}
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
|
||||
if showsLabels {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
percentageLabel(
|
||||
"account.credits.remaining",
|
||||
fraction: remainingFraction,
|
||||
color: palette.success
|
||||
)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
percentageLabel(
|
||||
"account.credits.used",
|
||||
fraction: usedFraction,
|
||||
color: palette.textTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func percentageLabel(
|
||||
_ titleKey: LocalizedStringKey,
|
||||
fraction: Double,
|
||||
color: Color
|
||||
) -> some View {
|
||||
let percentageText = fraction.formatted(
|
||||
.percent.precision(.fractionLength(0))
|
||||
)
|
||||
return HStack(spacing: Spacing.xxs) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(titleKey)
|
||||
Text(verbatim: percentageText)
|
||||
.monospacedDigit()
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountInvitationButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@State private var showsShareDrawer = false
|
||||
|
||||
let invitationURL: URL
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
showsShareDrawer = true
|
||||
} label: {
|
||||
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 38)
|
||||
.background(
|
||||
palette.success,
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.sheet(isPresented: $showsShareDrawer) {
|
||||
AccountInvitationActivityView(invitationURL: invitationURL)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountInvitationActivityView: UIViewControllerRepresentable {
|
||||
let invitationURL: URL
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(
|
||||
activityItems: [invitationURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(
|
||||
_ uiViewController: UIActivityViewController,
|
||||
context: Context
|
||||
) {}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
@MainActor
|
||||
struct IAPReviewScreenshotHarness: View {
|
||||
@StateObject private var purchaseManager: AccountCreditPurchaseManager
|
||||
|
||||
private let accountID = UUID(uuidString: "00000000-0000-0000-0000-000000000100")!
|
||||
|
||||
init() {
|
||||
_purchaseManager = StateObject(
|
||||
wrappedValue: AccountCreditPurchaseManager(
|
||||
service: IAPReviewAccountService(),
|
||||
store: IAPReviewCreditStore()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("account.credits.section") {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditsSection(snapshot: Self.snapshot)
|
||||
AccountCreditPurchaseSection(
|
||||
manager: purchaseManager,
|
||||
accountID: accountID,
|
||||
onGranted: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.navigationTitle("account.rewards.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
await purchaseManager.prepare(accountID: accountID)
|
||||
}
|
||||
}
|
||||
|
||||
private static let snapshot = AccountCenterSnapshot(
|
||||
account: AccountSession(
|
||||
accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000100")!,
|
||||
createdAtEpochSeconds: 0,
|
||||
displayName: "OSG User"
|
||||
),
|
||||
credits: AccountCreditSummary(balance: 1_000, usedCredits: 0),
|
||||
referralProfile: AccountReferralProfile(code: nil, boundCode: nil),
|
||||
referrals: []
|
||||
)
|
||||
}
|
||||
|
||||
private struct IAPReviewAccountService: AccountCenterServicing {
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
[
|
||||
AccountCreditProduct(productID: "500tks", credits: 500),
|
||||
AccountCreditProduct(productID: "1500tks", credits: 1_500),
|
||||
AccountCreditProduct(productID: "3000tks", credits: 3_000)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class IAPReviewCreditStore: AccountCreditStore {
|
||||
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||
let price: String
|
||||
switch productID {
|
||||
case "500tks":
|
||||
price = "¥8.00"
|
||||
case "1500tks":
|
||||
price = "¥18.00"
|
||||
case "3000tks":
|
||||
price = "¥28.00"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return AccountStoreProduct(id: productID, displayPrice: price)
|
||||
}
|
||||
|
||||
func purchase(
|
||||
productID: String,
|
||||
accountID: UUID
|
||||
) async throws -> AccountStorePurchaseOutcome {
|
||||
.userCancelled
|
||||
}
|
||||
|
||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
|
||||
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { $0.finish() }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private struct AccountActionRow: View {
|
||||
let titleKey: LocalizedStringKey
|
||||
let systemImage: String
|
||||
let color: Color
|
||||
let showsProgress: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(titleKey, systemImage: systemImage)
|
||||
.font(.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if showsProgress {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(color)
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccountStateCard: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
let systemImage: String
|
||||
let titleKey: String
|
||||
var actionKey: LocalizedStringKey?
|
||||
var showsProgress = false
|
||||
var action: () -> Void = {}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.md) {
|
||||
if showsProgress {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
.tint(palette.accent)
|
||||
} else {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
Text(LocalizedStringKey(titleKey))
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let actionKey {
|
||||
Button(actionKey, action: action)
|
||||
.font(.headline)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.frame(maxWidth: .infinity)
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// AccountCreditPurchaseManager.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// StoreKit credit purchases are isolated from the voluntary tip product.
|
||||
// Transactions finish only after the account server verifies and records them.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSGKeyboardHostSupport
|
||||
|
||||
struct AccountCreditPurchaseOption: Identifiable, Equatable {
|
||||
var id: String { productID }
|
||||
|
||||
let productID: String
|
||||
let credits: Int64
|
||||
let displayPrice: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AccountCreditPurchaseManager: ObservableObject {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case purchasing(productID: String)
|
||||
case pending
|
||||
case succeeded(credits: Int64)
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
enum HistoryPhase: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded([AccountCreditPurchaseRecord])
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
@Published private(set) var state: State = .idle
|
||||
@Published private(set) var options: [AccountCreditPurchaseOption] = []
|
||||
@Published private(set) var lastGrantedBalance: Int64?
|
||||
@Published private(set) var historyPhase: HistoryPhase = .idle
|
||||
@Published private(set) var isLoadingMoreHistory = false
|
||||
@Published private(set) var historyLoadMoreErrorKey: String?
|
||||
|
||||
private let service: any AccountCenterServicing
|
||||
private let store: any AccountCreditStore
|
||||
private var updatesTask: Task<Void, Never>?
|
||||
private var activeAccountID: UUID?
|
||||
private var configuredProductIDs: Set<String> = []
|
||||
private var nextHistoryCursor: String?
|
||||
|
||||
init(
|
||||
service: any AccountCenterServicing,
|
||||
store: any AccountCreditStore = LiveAccountCreditStore()
|
||||
) {
|
||||
self.service = service
|
||||
self.store = store
|
||||
}
|
||||
|
||||
deinit {
|
||||
updatesTask?.cancel()
|
||||
}
|
||||
|
||||
func prepare(accountID: UUID) async {
|
||||
guard activeAccountID != accountID || options.isEmpty else { return }
|
||||
reset(for: accountID)
|
||||
state = .loading
|
||||
do {
|
||||
let catalog = try await service.loadCreditProducts()
|
||||
configuredProductIDs = Set(catalog.map(\.productID))
|
||||
var loadedOptions: [AccountCreditPurchaseOption] = []
|
||||
for creditProduct in catalog {
|
||||
guard let product = try await store.product(for: creditProduct.productID) else {
|
||||
continue
|
||||
}
|
||||
loadedOptions.append(
|
||||
AccountCreditPurchaseOption(
|
||||
productID: creditProduct.productID,
|
||||
credits: creditProduct.credits,
|
||||
displayPrice: product.displayPrice
|
||||
)
|
||||
)
|
||||
}
|
||||
guard !loadedOptions.isEmpty else {
|
||||
throw AccountCreditPurchaseError.productUnavailable
|
||||
}
|
||||
options = loadedOptions.sorted { $0.credits < $1.credits }
|
||||
state = .idle
|
||||
listenForUpdates(accountID: accountID)
|
||||
_ = await reconcileUnfinished(accountID: accountID)
|
||||
} catch {
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func purchase(productID: String, accountID: UUID) async -> Bool {
|
||||
guard activeAccountID == accountID,
|
||||
options.contains(where: { $0.productID == productID }) else {
|
||||
state = .failed(messageKey: "account.storekit.error.productUnavailable")
|
||||
return false
|
||||
}
|
||||
state = .purchasing(productID: productID)
|
||||
do {
|
||||
switch try await store.purchase(productID: productID, accountID: accountID) {
|
||||
case .success(let verification):
|
||||
try await process(verification, accountID: accountID)
|
||||
return true
|
||||
case .pending:
|
||||
state = .pending
|
||||
case .userCancelled:
|
||||
state = .idle
|
||||
@unknown default:
|
||||
state = .failed(messageKey: "account.storekit.error.unknown")
|
||||
}
|
||||
} catch {
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clearTransientState() {
|
||||
switch state {
|
||||
case .succeeded, .failed, .pending:
|
||||
state = .idle
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func dismissSuccessMessage() {
|
||||
guard case .succeeded = state else { return }
|
||||
state = .idle
|
||||
}
|
||||
|
||||
func loadPurchaseHistory(accountID: UUID, force: Bool = false) async {
|
||||
guard activeAccountID == accountID else {
|
||||
historyPhase = .loaded([])
|
||||
return
|
||||
}
|
||||
if !force, case .loaded = historyPhase {
|
||||
return
|
||||
}
|
||||
|
||||
historyPhase = .loading
|
||||
historyLoadMoreErrorKey = nil
|
||||
do {
|
||||
let page = try await service.loadCreditPurchaseHistory(
|
||||
limit: 50,
|
||||
cursor: nil
|
||||
)
|
||||
guard activeAccountID == accountID else { return }
|
||||
nextHistoryCursor = page.nextCursor
|
||||
historyPhase = .loaded(page.items)
|
||||
} catch {
|
||||
guard activeAccountID == accountID else { return }
|
||||
nextHistoryCursor = nil
|
||||
historyPhase = .failed(messageKey: "account.purchaseHistory.error")
|
||||
}
|
||||
}
|
||||
|
||||
func loadNextPurchaseHistoryPage(accountID: UUID) async {
|
||||
guard activeAccountID == accountID,
|
||||
!isLoadingMoreHistory,
|
||||
let cursor = nextHistoryCursor,
|
||||
case .loaded(let currentItems) = historyPhase else {
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingMoreHistory = true
|
||||
historyLoadMoreErrorKey = nil
|
||||
defer { isLoadingMoreHistory = false }
|
||||
do {
|
||||
let page = try await service.loadCreditPurchaseHistory(
|
||||
limit: 50,
|
||||
cursor: cursor
|
||||
)
|
||||
guard activeAccountID == accountID,
|
||||
case .loaded = historyPhase else {
|
||||
return
|
||||
}
|
||||
let existingIDs = Set(currentItems.map(\.transactionID))
|
||||
let newItems = page.items.filter { !existingIDs.contains($0.transactionID) }
|
||||
nextHistoryCursor = page.nextCursor
|
||||
historyPhase = .loaded(currentItems + newItems)
|
||||
} catch {
|
||||
guard activeAccountID == accountID else { return }
|
||||
historyLoadMoreErrorKey = "account.purchaseHistory.loadMoreError"
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
updatesTask?.cancel()
|
||||
updatesTask = nil
|
||||
activeAccountID = nil
|
||||
options = []
|
||||
configuredProductIDs = []
|
||||
lastGrantedBalance = nil
|
||||
historyPhase = .idle
|
||||
isLoadingMoreHistory = false
|
||||
historyLoadMoreErrorKey = nil
|
||||
nextHistoryCursor = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func reconcileUnfinished(accountID: UUID) async -> Bool {
|
||||
var reconciled = false
|
||||
for await verification in store.unfinishedTransactions() {
|
||||
guard !Task.isCancelled else { break }
|
||||
do {
|
||||
try await process(verification, accountID: accountID)
|
||||
reconciled = true
|
||||
} catch {
|
||||
state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
}
|
||||
return reconciled
|
||||
}
|
||||
|
||||
private func listenForUpdates(accountID: UUID) {
|
||||
updatesTask?.cancel()
|
||||
let updates = store.transactionUpdates()
|
||||
updatesTask = Task { [weak self] in
|
||||
for await verification in updates {
|
||||
guard !Task.isCancelled else { return }
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await self.process(verification, accountID: accountID)
|
||||
} catch {
|
||||
self.state = .failed(messageKey: Self.messageKey(for: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func process(
|
||||
_ verification: AccountStoreVerification,
|
||||
accountID: UUID
|
||||
) async throws {
|
||||
guard case .verified(let transaction) = verification,
|
||||
transaction.appAccountToken == accountID,
|
||||
configuredProductIDs.contains(transaction.productID) else {
|
||||
throw AccountCreditPurchaseError.verificationFailed
|
||||
}
|
||||
let result = try await service.submitCreditTransaction(transaction.signedTransaction)
|
||||
guard result.transactionID == String(transaction.id),
|
||||
result.productID == transaction.productID else {
|
||||
throw AccountCreditPurchaseError.invalidServerResponse
|
||||
}
|
||||
await transaction.finish()
|
||||
lastGrantedBalance = result.balanceAfter
|
||||
historyPhase = .idle
|
||||
nextHistoryCursor = nil
|
||||
state = .succeeded(credits: result.creditsGranted)
|
||||
}
|
||||
|
||||
private func reset(for accountID: UUID) {
|
||||
updatesTask?.cancel()
|
||||
updatesTask = nil
|
||||
activeAccountID = accountID
|
||||
options = []
|
||||
configuredProductIDs = []
|
||||
lastGrantedBalance = nil
|
||||
historyPhase = .idle
|
||||
isLoadingMoreHistory = false
|
||||
historyLoadMoreErrorKey = nil
|
||||
nextHistoryCursor = nil
|
||||
}
|
||||
|
||||
private static func messageKey(for error: Error) -> String {
|
||||
if let purchaseError = error as? AccountCreditPurchaseError {
|
||||
switch purchaseError {
|
||||
case .productUnavailable:
|
||||
return "account.storekit.error.productUnavailable"
|
||||
case .verificationFailed:
|
||||
return "account.storekit.error.verification"
|
||||
case .invalidServerResponse:
|
||||
return "account.storekit.error.server"
|
||||
}
|
||||
}
|
||||
if let accountError = error as? AccountAPIError {
|
||||
switch accountError {
|
||||
case .unauthorized, .sessionUnavailable, .refreshTokenReuse:
|
||||
return "account.storekit.error.session"
|
||||
case .externalServiceUnavailable, .transport:
|
||||
return "account.storekit.error.network"
|
||||
case .conflict:
|
||||
return "account.storekit.error.conflict"
|
||||
default:
|
||||
return "account.storekit.error.server"
|
||||
}
|
||||
}
|
||||
return "account.storekit.error.unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private enum AccountCreditPurchaseError: Error {
|
||||
case productUnavailable
|
||||
case verificationFailed
|
||||
case invalidServerResponse
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// AccountCreditStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Isolates StoreKit so purchase orchestration remains hermetic and testable.
|
||||
|
||||
import Foundation
|
||||
import StoreKit
|
||||
|
||||
struct AccountStoreProduct: Equatable, Sendable {
|
||||
let id: String
|
||||
let displayPrice: String
|
||||
}
|
||||
|
||||
struct AccountStoreTransaction: Sendable {
|
||||
let id: UInt64
|
||||
let productID: String
|
||||
let appAccountToken: UUID?
|
||||
let purchaseDate: Date
|
||||
let signedTransaction: String
|
||||
|
||||
private let finishOperation: @Sendable () async -> Void
|
||||
|
||||
init(
|
||||
id: UInt64,
|
||||
productID: String,
|
||||
appAccountToken: UUID?,
|
||||
purchaseDate: Date = .distantPast,
|
||||
signedTransaction: String,
|
||||
finishOperation: @escaping @Sendable () async -> Void
|
||||
) {
|
||||
self.id = id
|
||||
self.productID = productID
|
||||
self.appAccountToken = appAccountToken
|
||||
self.purchaseDate = purchaseDate
|
||||
self.signedTransaction = signedTransaction
|
||||
self.finishOperation = finishOperation
|
||||
}
|
||||
|
||||
func finish() async {
|
||||
await finishOperation()
|
||||
}
|
||||
}
|
||||
|
||||
enum AccountStoreVerification: Sendable {
|
||||
case verified(AccountStoreTransaction)
|
||||
case unverified
|
||||
}
|
||||
|
||||
enum AccountStorePurchaseOutcome: Sendable {
|
||||
case success(AccountStoreVerification)
|
||||
case pending
|
||||
case userCancelled
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol AccountCreditStore {
|
||||
func product(for productID: String) async throws -> AccountStoreProduct?
|
||||
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome
|
||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification>
|
||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification>
|
||||
func allTransactions() -> AsyncStream<AccountStoreVerification>
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class LiveAccountCreditStore: AccountCreditStore {
|
||||
private var productsByID: [String: Product] = [:]
|
||||
|
||||
func product(for productID: String) async throws -> AccountStoreProduct? {
|
||||
guard let product = try await loadProduct(for: productID) else { return nil }
|
||||
return AccountStoreProduct(id: product.id, displayPrice: product.displayPrice)
|
||||
}
|
||||
|
||||
func purchase(productID: String, accountID: UUID) async throws -> AccountStorePurchaseOutcome {
|
||||
guard let product = try await loadProduct(for: productID) else {
|
||||
return .success(.unverified)
|
||||
}
|
||||
switch try await product.purchase(options: [.appAccountToken(accountID)]) {
|
||||
case .success(let verification):
|
||||
return .success(Self.map(verification))
|
||||
case .pending:
|
||||
return .pending
|
||||
case .userCancelled:
|
||||
return .userCancelled
|
||||
@unknown default:
|
||||
return .success(.unverified)
|
||||
}
|
||||
}
|
||||
|
||||
func unfinishedTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
Self.stream(from: Transaction.unfinished)
|
||||
}
|
||||
|
||||
func transactionUpdates() -> AsyncStream<AccountStoreVerification> {
|
||||
Self.stream(from: Transaction.updates)
|
||||
}
|
||||
|
||||
func allTransactions() -> AsyncStream<AccountStoreVerification> {
|
||||
Self.stream(from: Transaction.all)
|
||||
}
|
||||
|
||||
private func loadProduct(for productID: String) async throws -> Product? {
|
||||
if let product = productsByID[productID] {
|
||||
return product
|
||||
}
|
||||
let loaded = try await Product.products(for: [productID])
|
||||
guard let product = loaded.first(where: { $0.id == productID }) else { return nil }
|
||||
productsByID[productID] = product
|
||||
return product
|
||||
}
|
||||
|
||||
private static func map(_ verification: VerificationResult<Transaction>) -> AccountStoreVerification {
|
||||
guard case .verified(let transaction) = verification else { return .unverified }
|
||||
return .verified(
|
||||
AccountStoreTransaction(
|
||||
id: transaction.id,
|
||||
productID: transaction.productID,
|
||||
appAccountToken: transaction.appAccountToken,
|
||||
purchaseDate: transaction.purchaseDate,
|
||||
signedTransaction: verification.jwsRepresentation,
|
||||
finishOperation: { await transaction.finish() }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private static func stream(
|
||||
from transactions: Transaction.Transactions
|
||||
) -> AsyncStream<AccountStoreVerification> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
for await verification in transactions {
|
||||
guard !Task.isCancelled else { break }
|
||||
continuation.yield(map(verification))
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
continuation.onTermination = { _ in task.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// AccountModels.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Account-center domain models and narrow service boundaries. Transport DTOs
|
||||
// stay in the eventual auth/network adapter so this UI is not coupled to it.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let osgOpenAccountDeepLink = Notification.Name("osg.OpenAccountDeepLink")
|
||||
}
|
||||
|
||||
struct AccountSession: Equatable, Sendable {
|
||||
let accountID: UUID
|
||||
let createdAtEpochSeconds: Int64
|
||||
let displayName: String?
|
||||
|
||||
init(accountID: UUID, createdAtEpochSeconds: Int64, displayName: String? = nil) {
|
||||
self.accountID = accountID
|
||||
self.createdAtEpochSeconds = createdAtEpochSeconds
|
||||
self.displayName = displayName
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountCreditSummary: Equatable, Sendable {
|
||||
let balance: Int64
|
||||
let usedCredits: Int64
|
||||
}
|
||||
|
||||
struct AccountCreditProduct: Identifiable, Equatable, Sendable {
|
||||
var id: String { productID }
|
||||
|
||||
let productID: String
|
||||
let credits: Int64
|
||||
}
|
||||
|
||||
struct AccountCreditPurchase: Equatable, Sendable {
|
||||
let transactionID: String
|
||||
let productID: String
|
||||
let creditsGranted: Int64
|
||||
let balanceAfter: Int64
|
||||
let replayed: Bool
|
||||
}
|
||||
|
||||
enum AccountCreditPurchaseStatus: String, Equatable, Sendable {
|
||||
case credited
|
||||
}
|
||||
|
||||
struct AccountCreditPurchaseRecord: Identifiable, Equatable, Sendable {
|
||||
var id: String { transactionID }
|
||||
|
||||
let transactionID: String
|
||||
let productID: String
|
||||
let creditsGranted: Int64
|
||||
let balanceAfter: Int64
|
||||
let purchasedAt: Date
|
||||
let status: AccountCreditPurchaseStatus
|
||||
}
|
||||
|
||||
struct AccountCreditPurchaseHistoryPage: Equatable, Sendable {
|
||||
let items: [AccountCreditPurchaseRecord]
|
||||
let nextCursor: String?
|
||||
}
|
||||
|
||||
enum AccountReferralStatus: String, CaseIterable, Equatable, Sendable {
|
||||
case pending
|
||||
case rewarded
|
||||
case ineligible
|
||||
}
|
||||
|
||||
struct AccountReferral: Identifiable, Equatable, Sendable {
|
||||
let id: UUID
|
||||
let status: AccountReferralStatus
|
||||
let createdAtEpochSeconds: Int64?
|
||||
let rewardCredits: Int64?
|
||||
}
|
||||
|
||||
struct AccountReferralProfile: Equatable, Sendable {
|
||||
let code: String?
|
||||
let boundCode: String?
|
||||
let inviterRewardCredits: Int64?
|
||||
let inviteeRewardCredits: Int64?
|
||||
|
||||
init(
|
||||
code: String?,
|
||||
boundCode: String?,
|
||||
inviterRewardCredits: Int64? = nil,
|
||||
inviteeRewardCredits: Int64? = nil
|
||||
) {
|
||||
self.code = code
|
||||
self.boundCode = boundCode
|
||||
self.inviterRewardCredits = inviterRewardCredits
|
||||
self.inviteeRewardCredits = inviteeRewardCredits
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountCenterSnapshot: Equatable, Sendable {
|
||||
let account: AccountSession
|
||||
let credits: AccountCreditSummary
|
||||
let referralProfile: AccountReferralProfile
|
||||
let referrals: [AccountReferral]
|
||||
}
|
||||
|
||||
struct AccountReferralSummary: Equatable, Sendable {
|
||||
let pending: Int
|
||||
let rewarded: Int
|
||||
let ineligible: Int
|
||||
|
||||
init(pending: Int, rewarded: Int, ineligible: Int) {
|
||||
self.pending = pending
|
||||
self.rewarded = rewarded
|
||||
self.ineligible = ineligible
|
||||
}
|
||||
|
||||
init(referrals: [AccountReferral]) {
|
||||
pending = referrals.count { $0.status == .pending }
|
||||
rewarded = referrals.count { $0.status == .rewarded }
|
||||
ineligible = referrals.count { $0.status == .ineligible }
|
||||
}
|
||||
}
|
||||
|
||||
struct AppleAuthorizationPayload: Equatable, Sendable {
|
||||
let identityToken: String
|
||||
let authorizationCode: String
|
||||
let nonce: String
|
||||
let displayName: String?
|
||||
|
||||
init(
|
||||
identityToken: String,
|
||||
authorizationCode: String,
|
||||
nonce: String,
|
||||
displayName: String? = nil
|
||||
) {
|
||||
self.identityToken = identityToken
|
||||
self.authorizationCode = authorizationCode
|
||||
self.nonce = nonce
|
||||
self.displayName = displayName
|
||||
}
|
||||
}
|
||||
|
||||
protocol AccountSessionServicing: Sendable {
|
||||
func restoreSession() async throws -> AccountSession?
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession
|
||||
func signOut() async throws
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws
|
||||
func prepareManagedGateway() async throws
|
||||
func clearManagedGateway() async
|
||||
}
|
||||
|
||||
extension AccountSessionServicing {
|
||||
func prepareManagedGateway() async throws {}
|
||||
func clearManagedGateway() async {}
|
||||
}
|
||||
|
||||
protocol AccountCenterServicing: Sendable {
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot
|
||||
func loadAccountCenter(
|
||||
cachedSnapshot: AccountCenterSnapshot?
|
||||
) async throws -> AccountCenterSnapshot
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession
|
||||
func createReferralCode() async throws -> String
|
||||
func redeemReferral(code: String) async throws
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct]
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase
|
||||
func loadCreditPurchaseHistory(
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> AccountCreditPurchaseHistoryPage
|
||||
}
|
||||
|
||||
extension AccountCenterServicing {
|
||||
func loadAccountCenter(
|
||||
cachedSnapshot: AccountCenterSnapshot?
|
||||
) async throws -> AccountCenterSnapshot {
|
||||
try await loadAccountCenter()
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func loadCreditPurchaseHistory(
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
struct AccountDependencies: Sendable {
|
||||
let sessionService: any AccountSessionServicing
|
||||
let centerService: any AccountCenterServicing
|
||||
|
||||
static let unavailable: AccountDependencies = {
|
||||
let service = UnavailableAccountService()
|
||||
return AccountDependencies(sessionService: service, centerService: service)
|
||||
}()
|
||||
}
|
||||
|
||||
enum AccountIntegrationError: Error, Equatable, Sendable {
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private struct UnavailableAccountService: AccountSessionServicing, AccountCenterServicing {
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
nil
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func signOut() async throws {}
|
||||
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func prepareManagedGateway() async throws {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func clearManagedGateway() async {}
|
||||
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||
throw AccountIntegrationError.unavailable
|
||||
}
|
||||
}
|
||||
|
||||
enum ReferralUniversalLink {
|
||||
static let host = "osglab.com"
|
||||
static let codeLength = 22
|
||||
|
||||
static func code(from url: URL) -> String? {
|
||||
guard url.scheme?.lowercased() == "https",
|
||||
url.host?.lowercased() == host
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let components = url.path.split(separator: "/", omittingEmptySubsequences: true)
|
||||
guard components.count == 2, components[0] == "i" else { return nil }
|
||||
|
||||
let code = String(components[1])
|
||||
guard isValid(code: code) else { return nil }
|
||||
return code
|
||||
}
|
||||
|
||||
static func isValid(code: String) -> Bool {
|
||||
guard code.utf8.count == codeLength else { return false }
|
||||
return code.utf8.allSatisfy { byte in
|
||||
(48...57).contains(byte)
|
||||
|| (65...90).contains(byte)
|
||||
|| (97...122).contains(byte)
|
||||
|| byte == 45
|
||||
|| byte == 95
|
||||
}
|
||||
}
|
||||
|
||||
static func invitationURL(for code: String) -> URL? {
|
||||
guard isValid(code: code) else { return nil }
|
||||
return URL(string: "https://\(host)/i/\(code)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
protocol PendingReferralCodeStoring: AnyObject {
|
||||
var code: String? { get }
|
||||
func save(_ code: String)
|
||||
func clear()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
|
||||
private static let storageKey = "account.pendingReferralCode.v1"
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
var code: String? {
|
||||
guard let value = defaults.string(forKey: Self.storageKey),
|
||||
ReferralUniversalLink.isValid(code: value)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func save(_ code: String) {
|
||||
guard ReferralUniversalLink.isValid(code: code) else { return }
|
||||
defaults.set(code, forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
func clear() {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// AccountPurchaseHistoryView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Displays verified StoreKit credit purchases for the active OSG account.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AccountPurchaseHistoryView: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject var manager: AccountCreditPurchaseManager
|
||||
|
||||
let accountID: UUID
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch manager.historyPhase {
|
||||
case .idle, .loading:
|
||||
ProgressView("account.purchaseHistory.loading")
|
||||
.tint(palette.accent)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loaded(let records):
|
||||
if records.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"account.purchaseHistory.empty",
|
||||
systemImage: "clock.arrow.circlepath"
|
||||
)
|
||||
} else {
|
||||
purchaseList(records)
|
||||
}
|
||||
case .failed(let messageKey):
|
||||
VStack(spacing: Spacing.md) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.accessibilityHidden(true)
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Task {
|
||||
await manager.loadPurchaseHistory(
|
||||
accountID: accountID,
|
||||
force: true
|
||||
)
|
||||
}
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.purchaseHistory.title")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
.task {
|
||||
await manager.loadPurchaseHistory(accountID: accountID)
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseList(
|
||||
_ records: [AccountCreditPurchaseRecord]
|
||||
) -> some View {
|
||||
List {
|
||||
ForEach(records) { record in
|
||||
purchaseRow(record)
|
||||
.listRowBackground(palette.surface)
|
||||
.listRowSeparatorTint(palette.divider)
|
||||
.task {
|
||||
guard record.id == records.last?.id else { return }
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
}
|
||||
|
||||
if manager.isLoadingMoreHistory {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Spacer()
|
||||
}
|
||||
.listRowBackground(palette.surface)
|
||||
} else if let errorKey = manager.historyLoadMoreErrorKey {
|
||||
Button {
|
||||
Task {
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
} label: {
|
||||
Text(LocalizedStringKey(errorKey))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(palette.surface)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(palette.background)
|
||||
.accessibilityIdentifier("account.purchaseHistory.list")
|
||||
.refreshable {
|
||||
await manager.loadPurchaseHistory(accountID: accountID, force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func purchaseRow(
|
||||
_ record: AccountCreditPurchaseRecord
|
||||
) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: Spacing.xxs) {
|
||||
Text("+")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.success)
|
||||
Text(record.creditsGranted, format: .number.grouping(.automatic))
|
||||
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
Text(purchaseDateText(record.purchasedAt))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
|
||||
VStack(alignment: .trailing, spacing: Spacing.xxs) {
|
||||
Text(balanceAfterText(record.balanceAfter))
|
||||
.font(TypeStyle.caption.monospacedDigit())
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text("account.purchaseHistory.status.credited")
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.success)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, Spacing.xs)
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private func purchaseDateText(_ date: Date) -> String {
|
||||
date.formatted(
|
||||
.dateTime
|
||||
.year()
|
||||
.month()
|
||||
.day()
|
||||
.hour()
|
||||
.minute()
|
||||
.locale(config.uiLanguage.swiftUILocale)
|
||||
)
|
||||
}
|
||||
|
||||
private func balanceAfterText(_ balance: Int64) -> String {
|
||||
AppL10n.format(
|
||||
"account.purchaseHistory.balanceAfter",
|
||||
language: config.uiLanguage,
|
||||
balance.formatted(.number.grouping(.automatic))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// AccountSessionCoordinator.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Main-actor state machine for optional account features. Local and BYOK
|
||||
// features never consult this coordinator and remain available when signed out.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AccountSessionCoordinator: ObservableObject {
|
||||
enum SessionPhase: Equatable {
|
||||
case restoring
|
||||
case signedOut
|
||||
case signedIn(AccountSession)
|
||||
}
|
||||
|
||||
enum SnapshotPhase: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(AccountCenterSnapshot)
|
||||
case failed(messageKey: String)
|
||||
}
|
||||
|
||||
enum Operation: Equatable {
|
||||
case signingIn
|
||||
case signingOut
|
||||
case redeemingReferral
|
||||
case preparingManagedGateway
|
||||
case updatingProfile
|
||||
case deletingAccount
|
||||
}
|
||||
|
||||
@Published private(set) var sessionPhase: SessionPhase = .restoring
|
||||
@Published private(set) var snapshotPhase: SnapshotPhase = .idle
|
||||
@Published private(set) var operation: Operation?
|
||||
@Published private(set) var operationErrorKey: String?
|
||||
@Published private(set) var pendingReferralCode: String?
|
||||
@Published private(set) var shouldPresentAccountCenter = false
|
||||
@Published private(set) var lastAccountRefreshAt: Date?
|
||||
@Published private(set) var isRefreshingAccountData = false
|
||||
@Published private(set) var accountRefreshErrorKey: String?
|
||||
|
||||
let creditPurchases: AccountCreditPurchaseManager
|
||||
|
||||
private let sessionService: any AccountSessionServicing
|
||||
private let centerService: any AccountCenterServicing
|
||||
private let pendingReferralStore: any PendingReferralCodeStoring
|
||||
private let accountRefreshInterval: TimeInterval
|
||||
private let now: () -> Date
|
||||
private var didAttemptRestore = false
|
||||
private var accountRefreshTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
dependencies: AccountDependencies,
|
||||
creditStore: any AccountCreditStore = LiveAccountCreditStore(),
|
||||
pendingReferralStore: any PendingReferralCodeStoring =
|
||||
UserDefaultsPendingReferralCodeStore(),
|
||||
accountRefreshInterval: TimeInterval = 10 * 60,
|
||||
now: @escaping () -> Date = Date.init
|
||||
) {
|
||||
sessionService = dependencies.sessionService
|
||||
centerService = dependencies.centerService
|
||||
creditPurchases = AccountCreditPurchaseManager(
|
||||
service: dependencies.centerService,
|
||||
store: creditStore
|
||||
)
|
||||
self.pendingReferralStore = pendingReferralStore
|
||||
self.accountRefreshInterval = accountRefreshInterval
|
||||
self.now = now
|
||||
pendingReferralCode = pendingReferralStore.code
|
||||
}
|
||||
|
||||
var isSignedIn: Bool {
|
||||
if case .signedIn = sessionPhase {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var accountID: UUID? {
|
||||
guard case let .signedIn(session) = sessionPhase else { return nil }
|
||||
return session.accountID
|
||||
}
|
||||
|
||||
func restoreIfNeeded() async {
|
||||
guard !didAttemptRestore else { return }
|
||||
didAttemptRestore = true
|
||||
sessionPhase = .restoring
|
||||
operationErrorKey = nil
|
||||
|
||||
do {
|
||||
guard let session = try await sessionService.restoreSession() else {
|
||||
sessionPhase = .signedOut
|
||||
return
|
||||
}
|
||||
sessionPhase = .signedIn(session)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
sessionPhase = .signedOut
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.restore"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handleIncomingURL(_ url: URL) -> Bool {
|
||||
guard let code = ReferralUniversalLink.code(from: url) else { return false }
|
||||
|
||||
pendingReferralStore.save(code)
|
||||
pendingReferralCode = code
|
||||
shouldPresentAccountCenter = true
|
||||
operationErrorKey = nil
|
||||
|
||||
if isSignedIn {
|
||||
Task {
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func consumeAccountCenterPresentation() -> Bool {
|
||||
guard shouldPresentAccountCenter else { return false }
|
||||
shouldPresentAccountCenter = false
|
||||
return true
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async {
|
||||
guard operation == nil else { return }
|
||||
operation = .signingIn
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
|
||||
do {
|
||||
let session = try await sessionService.signIn(with: payload)
|
||||
sessionPhase = .signedIn(session)
|
||||
await redeemPendingReferralIfNeeded()
|
||||
await refreshAccountData(force: true)
|
||||
} catch {
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.signIn"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the single account snapshot consumed by Settings and Account Center.
|
||||
/// Page entry uses the freshness window; explicit user actions force refresh.
|
||||
func refreshAccountData(force: Bool = false) async {
|
||||
guard let accountID else {
|
||||
snapshotPhase = .idle
|
||||
return
|
||||
}
|
||||
if let accountRefreshTask {
|
||||
await accountRefreshTask.value
|
||||
return
|
||||
}
|
||||
if !force,
|
||||
case .loaded = snapshotPhase,
|
||||
let lastAccountRefreshAt,
|
||||
now().timeIntervalSince(lastAccountRefreshAt) < accountRefreshInterval {
|
||||
return
|
||||
}
|
||||
// Keep the current snapshot visible while refreshing in the background.
|
||||
if case .loaded = snapshotPhase {
|
||||
// No state transition is needed for an existing snapshot.
|
||||
} else {
|
||||
snapshotPhase = .loading
|
||||
}
|
||||
|
||||
let task = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.performAccountRefresh(for: accountID)
|
||||
}
|
||||
accountRefreshTask = task
|
||||
await task.value
|
||||
accountRefreshTask = nil
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async -> Bool {
|
||||
guard operation == nil, isSignedIn else { return false }
|
||||
operation = .updatingProfile
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
|
||||
do {
|
||||
let account = try await centerService.updateDisplayName(displayName)
|
||||
sessionPhase = .signedIn(account)
|
||||
if case let .loaded(snapshot) = snapshotPhase {
|
||||
snapshotPhase = .loaded(
|
||||
AccountCenterSnapshot(
|
||||
account: account,
|
||||
credits: snapshot.credits,
|
||||
referralProfile: snapshot.referralProfile,
|
||||
referrals: snapshot.referrals
|
||||
)
|
||||
)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.updateProfile"
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func prepareManagedGateway() async -> Bool {
|
||||
guard operation == nil, isSignedIn else { return false }
|
||||
operation = .preparingManagedGateway
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
do {
|
||||
try await sessionService.prepareManagedGateway()
|
||||
return true
|
||||
} catch {
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.managedGateway"
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func clearManagedGateway() async {
|
||||
await sessionService.clearManagedGateway()
|
||||
}
|
||||
|
||||
func signOut() async {
|
||||
guard operation == nil, isSignedIn else { return }
|
||||
operation = .signingOut
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
|
||||
do {
|
||||
try await sessionService.signOut()
|
||||
creditPurchases.reset()
|
||||
clearAccountRefreshState()
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
} catch {
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.signOut"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async {
|
||||
guard operation == nil, isSignedIn else { return }
|
||||
operation = .deletingAccount
|
||||
operationErrorKey = nil
|
||||
defer { operation = nil }
|
||||
|
||||
do {
|
||||
try await sessionService.deleteAccount(with: payload)
|
||||
creditPurchases.reset()
|
||||
clearAccountRefreshState()
|
||||
sessionPhase = .signedOut
|
||||
snapshotPhase = .idle
|
||||
pendingReferralStore.clear()
|
||||
pendingReferralCode = nil
|
||||
} catch {
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.delete"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func recordAppleAuthorizationFailure() {
|
||||
operationErrorKey = "account.error.appleAuthorization"
|
||||
}
|
||||
|
||||
func dismissOperationError() {
|
||||
operationErrorKey = nil
|
||||
}
|
||||
|
||||
private func performAccountRefresh(for accountID: UUID) async {
|
||||
isRefreshingAccountData = true
|
||||
accountRefreshErrorKey = nil
|
||||
defer { isRefreshingAccountData = false }
|
||||
|
||||
do {
|
||||
let cachedSnapshot: AccountCenterSnapshot?
|
||||
if case let .loaded(snapshot) = snapshotPhase {
|
||||
cachedSnapshot = snapshot
|
||||
} else {
|
||||
cachedSnapshot = nil
|
||||
}
|
||||
let snapshot = try await centerService.loadAccountCenter(
|
||||
cachedSnapshot: cachedSnapshot
|
||||
)
|
||||
guard self.accountID == accountID,
|
||||
snapshot.account.accountID == accountID else {
|
||||
return
|
||||
}
|
||||
snapshotPhase = .loaded(snapshot)
|
||||
sessionPhase = .signedIn(snapshot.account)
|
||||
lastAccountRefreshAt = now()
|
||||
} catch {
|
||||
guard self.accountID == accountID else { return }
|
||||
let messageKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.load"
|
||||
)
|
||||
accountRefreshErrorKey = messageKey
|
||||
if case .loaded = snapshotPhase {
|
||||
return
|
||||
}
|
||||
snapshotPhase = .failed(messageKey: messageKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func clearAccountRefreshState() {
|
||||
accountRefreshTask?.cancel()
|
||||
accountRefreshTask = nil
|
||||
lastAccountRefreshAt = nil
|
||||
isRefreshingAccountData = false
|
||||
accountRefreshErrorKey = nil
|
||||
}
|
||||
|
||||
private func redeemPendingReferralIfNeeded() async {
|
||||
guard isSignedIn, let code = pendingReferralStore.code else { return }
|
||||
|
||||
operation = .redeemingReferral
|
||||
do {
|
||||
try await centerService.redeemReferral(code: code)
|
||||
pendingReferralStore.clear()
|
||||
pendingReferralCode = nil
|
||||
} catch {
|
||||
pendingReferralCode = code
|
||||
operationErrorKey = errorMessageKey(
|
||||
for: error,
|
||||
fallback: "account.error.redeemReferral"
|
||||
)
|
||||
}
|
||||
operation = nil
|
||||
}
|
||||
|
||||
private func errorMessageKey(for error: Error, fallback: String) -> String {
|
||||
if error as? AccountIntegrationError == .unavailable {
|
||||
return "account.error.unavailable"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
// LiveAccountServices.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Adapts the host-private account protocol to the account-center UI. Account
|
||||
// sessions never enter App Group storage; only scope-limited gateway grants do.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
|
||||
@MainActor
|
||||
enum LiveAccountDependencyFactory {
|
||||
static func make(bundle: Bundle = .main) -> AccountDependencies {
|
||||
guard let accessGroup = bundle.object(
|
||||
forInfoDictionaryKey: "OSGPrivateKeychainAccessGroup"
|
||||
) as? String,
|
||||
let descriptor = try? HostPrivateAccountKeychainDescriptor(
|
||||
accessGroup: accessGroup
|
||||
) else {
|
||||
return .unavailable
|
||||
}
|
||||
|
||||
let sessionVault = HostPrivateAccountKeychain(descriptor: descriptor)
|
||||
let apiClient = AccountAPIClient(sessionVault: sessionVault)
|
||||
let integrity = DeviceIntegrityCoordinator(
|
||||
apiClient: apiClient,
|
||||
keyStateStore: sessionVault
|
||||
)
|
||||
let grantStore = GatewayGrantKeychainStore()
|
||||
let grants = GatewayGrantCoordinator(store: grantStore)
|
||||
let service = LiveAccountService(
|
||||
apiClient: apiClient,
|
||||
integrity: integrity,
|
||||
grants: grants,
|
||||
grantStore: grantStore,
|
||||
configuration: AppGroupStore()
|
||||
)
|
||||
return AccountDependencies(sessionService: service, centerService: service)
|
||||
}
|
||||
}
|
||||
|
||||
actor AccountCenterSnapshotLoader {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init(apiClient: AccountAPIClient) {
|
||||
self.apiClient = apiClient
|
||||
}
|
||||
|
||||
func load(
|
||||
cachedSnapshot: AccountCenterSnapshot?
|
||||
) async throws -> AccountCenterSnapshot {
|
||||
async let account = apiClient.account()
|
||||
async let balance = resource(CreditBalanceDTO.self, .creditsBalance)
|
||||
async let profile = optionalResource(
|
||||
ReferralProfileDTO.self,
|
||||
.referralProfile,
|
||||
diagnosticName: "referral-profile"
|
||||
)
|
||||
async let referrals = optionalResource(
|
||||
[ReferralBindingDTO].self,
|
||||
.referrals(limit: 50),
|
||||
diagnosticName: "referrals"
|
||||
)
|
||||
async let campaigns = optionalResource(
|
||||
[ReferralCampaignDTO].self,
|
||||
.referralCampaigns,
|
||||
diagnosticName: "referral-campaigns"
|
||||
)
|
||||
|
||||
let core: (OSGAccount, CreditBalanceDTO)
|
||||
do {
|
||||
core = try await (account, balance)
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"account refresh core failed error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
let optionalValues = await (profile, referrals, campaigns)
|
||||
let invitationCode = await resolvedInvitationCode(
|
||||
profile: optionalValues.0,
|
||||
cachedProfile: cachedSnapshot?.referralProfile
|
||||
)
|
||||
let campaign = optionalValues.2?.first {
|
||||
$0.id == optionalValues.0?.code?.campaignId
|
||||
} ?? optionalValues.2?.first
|
||||
let referralProfile = if optionalValues.0 == nil,
|
||||
let cachedProfile = cachedSnapshot?.referralProfile {
|
||||
cachedProfile
|
||||
} else {
|
||||
AccountReferralProfile(
|
||||
code: invitationCode,
|
||||
boundCode: cachedSnapshot?.referralProfile.boundCode,
|
||||
inviterRewardCredits: campaign?.inviterRewardCredits
|
||||
?? cachedSnapshot?.referralProfile.inviterRewardCredits,
|
||||
inviteeRewardCredits: campaign?.inviteeRewardCredits
|
||||
?? cachedSnapshot?.referralProfile.inviteeRewardCredits
|
||||
)
|
||||
}
|
||||
let loadedReferrals = optionalValues.1?.map {
|
||||
AccountReferral(
|
||||
id: UUID(),
|
||||
status: Self.referralStatus($0.rewardStatus),
|
||||
createdAtEpochSeconds: Self.epochSeconds($0.boundAt),
|
||||
rewardCredits: nil
|
||||
)
|
||||
} ?? cachedSnapshot?.referrals ?? []
|
||||
|
||||
return AccountCenterSnapshot(
|
||||
account: AccountSession(
|
||||
accountID: core.0.id,
|
||||
createdAtEpochSeconds: core.0.createdAtEpochSeconds,
|
||||
displayName: core.0.displayName
|
||||
),
|
||||
credits: AccountCreditSummary(
|
||||
balance: core.1.balance,
|
||||
usedCredits: core.1.lifetimeUsed ?? 0
|
||||
),
|
||||
referralProfile: referralProfile,
|
||||
referrals: loadedReferrals
|
||||
)
|
||||
}
|
||||
|
||||
private func resource<Value: Decodable & Sendable>(
|
||||
_ type: Value.Type,
|
||||
_ resource: AccountAuthorizedResource
|
||||
) async throws -> Value {
|
||||
let data = try await apiClient.authorizedResourceData(resource)
|
||||
return try decoder.decode(type, from: data)
|
||||
}
|
||||
|
||||
private func optionalResource<Value: Decodable & Sendable>(
|
||||
_ type: Value.Type,
|
||||
_ resource: AccountAuthorizedResource,
|
||||
diagnosticName: String
|
||||
) async -> Value? {
|
||||
do {
|
||||
return try await self.resource(type, resource)
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"account refresh optional=\(diagnosticName) fallback=cache "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func resolvedInvitationCode(
|
||||
profile: ReferralProfileDTO?,
|
||||
cachedProfile: AccountReferralProfile?
|
||||
) async -> String? {
|
||||
if let code = profile?.code?.code ?? cachedProfile?.code {
|
||||
return code
|
||||
}
|
||||
do {
|
||||
let data = try await apiClient.authorizedResourceData(.createReferralCode)
|
||||
return try decoder.decode(ReferralCodeDTO.self, from: data).code
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"account refresh optional=referral-code fallback=cache "
|
||||
+ "error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
return cachedProfile?.code
|
||||
}
|
||||
}
|
||||
|
||||
private static func referralStatus(_ value: String) -> AccountReferralStatus {
|
||||
switch value.uppercased() {
|
||||
case "REWARDED":
|
||||
return .rewarded
|
||||
case "INELIGIBLE_BUDGET":
|
||||
return .ineligible
|
||||
default:
|
||||
return .pending
|
||||
}
|
||||
}
|
||||
|
||||
private static func epochSeconds(_ value: String) -> Int64? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let standard = ISO8601DateFormatter()
|
||||
standard.formatOptions = [.withInternetDateTime]
|
||||
let date = fractional.date(from: value) ?? standard.date(from: value)
|
||||
return date.map { Int64($0.timeIntervalSince1970) }
|
||||
}
|
||||
}
|
||||
|
||||
private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing {
|
||||
private let apiClient: AccountAPIClient
|
||||
private let accountCenterLoader: AccountCenterSnapshotLoader
|
||||
private let integrity: DeviceIntegrityCoordinator
|
||||
private let grants: GatewayGrantCoordinator
|
||||
private let grantStore: any GatewayGrantCredentialStore
|
||||
private let configuration: AppGroupStore
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
|
||||
init(
|
||||
apiClient: AccountAPIClient,
|
||||
integrity: DeviceIntegrityCoordinator,
|
||||
grants: GatewayGrantCoordinator,
|
||||
grantStore: any GatewayGrantCredentialStore,
|
||||
configuration: AppGroupStore
|
||||
) {
|
||||
self.apiClient = apiClient
|
||||
accountCenterLoader = AccountCenterSnapshotLoader(apiClient: apiClient)
|
||||
self.integrity = integrity
|
||||
self.grants = grants
|
||||
self.grantStore = grantStore
|
||||
self.configuration = configuration
|
||||
}
|
||||
|
||||
func restoreSession() async throws -> AccountSession? {
|
||||
guard let cachedSession = try await apiClient.currentSession() else { return nil }
|
||||
let account: OSGAccount?
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch let error as AccountAPIError {
|
||||
switch error {
|
||||
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
|
||||
return nil
|
||||
default:
|
||||
account = nil
|
||||
}
|
||||
} catch {
|
||||
account = nil
|
||||
}
|
||||
let session = try await apiClient.currentSession() ?? cachedSession
|
||||
await synchronizeManagedGrant()
|
||||
return uiSession(
|
||||
session,
|
||||
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
|
||||
displayName: account?.displayName
|
||||
)
|
||||
}
|
||||
|
||||
func signIn(with payload: AppleAuthorizationPayload) async throws -> AccountSession {
|
||||
let credential = AppleSignInCredential(
|
||||
identityToken: payload.identityToken,
|
||||
authorizationCode: payload.authorizationCode
|
||||
)
|
||||
let evidence: DeviceIntegrityEvidence
|
||||
do {
|
||||
evidence = try await integrity.evidenceForAppleSignIn(
|
||||
credential: credential,
|
||||
rawNonce: payload.nonce
|
||||
)
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=integrity error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
OSGDiag.log(
|
||||
"signIn integrity ready deviceCheck=\(evidence.deviceCheckToken == nil ? 0 : 1) "
|
||||
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
|
||||
category: "account"
|
||||
)
|
||||
let session: OSGKeyboardHostSupport.AccountSession
|
||||
do {
|
||||
session = try await apiClient.signInWithApple(
|
||||
AppleSignInRequest(
|
||||
identityToken: payload.identityToken,
|
||||
authorizationCode: payload.authorizationCode,
|
||||
nonce: payload.nonce,
|
||||
displayName: payload.displayName,
|
||||
deviceCheckToken: evidence.deviceCheckToken,
|
||||
appAttest: evidence.appAttest
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
await synchronizeManagedGrant()
|
||||
let account: OSGAccount
|
||||
do {
|
||||
account = try await apiClient.account()
|
||||
} catch {
|
||||
OSGDiag.log(
|
||||
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
|
||||
category: "account"
|
||||
)
|
||||
throw error
|
||||
}
|
||||
OSGDiag.log("signIn completed", category: "account")
|
||||
return uiSession(
|
||||
session,
|
||||
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||
displayName: account.displayName
|
||||
)
|
||||
}
|
||||
|
||||
func signOut() async throws {
|
||||
await revokeManagedGrantIfPossible()
|
||||
do {
|
||||
try await apiClient.logout()
|
||||
} catch {
|
||||
try? await grants.clearGrant()
|
||||
configuration.setCredentialSource(.byok)
|
||||
throw error
|
||||
}
|
||||
try? await grants.clearGrant()
|
||||
configuration.setCredentialSource(.byok)
|
||||
}
|
||||
|
||||
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
|
||||
try await apiClient.deleteAccount(
|
||||
identityToken: payload.identityToken,
|
||||
authorizationCode: payload.authorizationCode,
|
||||
nonce: payload.nonce
|
||||
)
|
||||
try? await grants.clearGrant()
|
||||
await integrity.clearLocalKeyState()
|
||||
configuration.setCredentialSource(.byok)
|
||||
}
|
||||
|
||||
func prepareManagedGateway() async throws {
|
||||
guard try await apiClient.currentSession() != nil else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
try await ensureManagedGrant()
|
||||
}
|
||||
|
||||
func clearManagedGateway() async {
|
||||
await revokeManagedGrantIfPossible()
|
||||
}
|
||||
|
||||
func loadAccountCenter() async throws -> AccountCenterSnapshot {
|
||||
try await accountCenterLoader.load(cachedSnapshot: nil)
|
||||
}
|
||||
|
||||
func loadAccountCenter(
|
||||
cachedSnapshot: AccountCenterSnapshot?
|
||||
) async throws -> AccountCenterSnapshot {
|
||||
try await accountCenterLoader.load(cachedSnapshot: cachedSnapshot)
|
||||
}
|
||||
|
||||
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
|
||||
let account = try await apiClient.updateAccount(displayName: displayName)
|
||||
return AccountSession(
|
||||
accountID: account.id,
|
||||
createdAtEpochSeconds: account.createdAtEpochSeconds,
|
||||
displayName: account.displayName
|
||||
)
|
||||
}
|
||||
|
||||
func createReferralCode() async throws -> String {
|
||||
let data = try await apiClient.authorizedResourceData(.createReferralCode)
|
||||
return try decoder.decode(ReferralCodeDTO.self, from: data).code
|
||||
}
|
||||
|
||||
func redeemReferral(code: String) async throws {
|
||||
struct Request: Encodable { let code: String }
|
||||
let body = try encoder.encode(Request(code: code))
|
||||
_ = try await apiClient.authorizedResourceData(.redeemReferral, body: body)
|
||||
}
|
||||
|
||||
func loadCreditProducts() async throws -> [AccountCreditProduct] {
|
||||
let products = try await resource([StoreKitProductDTO].self, .storeKitProducts)
|
||||
return products.map {
|
||||
AccountCreditProduct(productID: $0.productId, credits: $0.credits)
|
||||
}
|
||||
}
|
||||
|
||||
func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase {
|
||||
let request = StoreKitTransactionRequestDTO(signedTransaction: signedTransaction)
|
||||
let data = try await apiClient.authorizedResourceData(
|
||||
.submitStoreKitTransaction,
|
||||
body: try encoder.encode(request)
|
||||
)
|
||||
let purchase = try decoder.decode(StoreKitPurchaseDTO.self, from: data)
|
||||
return AccountCreditPurchase(
|
||||
transactionID: purchase.transactionId,
|
||||
productID: purchase.productId,
|
||||
creditsGranted: purchase.creditsGranted,
|
||||
balanceAfter: purchase.balanceAfter,
|
||||
replayed: purchase.replayed
|
||||
)
|
||||
}
|
||||
|
||||
func loadCreditPurchaseHistory(
|
||||
limit: Int,
|
||||
cursor: String?
|
||||
) async throws -> AccountCreditPurchaseHistoryPage {
|
||||
let data = try await apiClient.authorizedResourceData(
|
||||
.storeKitTransactions(limit: limit, cursor: cursor)
|
||||
)
|
||||
let response = try decoder.decode(
|
||||
StoreKitTransactionHistoryResponseDTO.self,
|
||||
from: data
|
||||
)
|
||||
let items = try response.items.map { item in
|
||||
guard let purchasedAt = Self.storeKitPurchaseDate(item.purchasedAt),
|
||||
let status = AccountCreditPurchaseStatus(rawValue: item.status) else {
|
||||
throw AccountAPIError.decoding
|
||||
}
|
||||
return AccountCreditPurchaseRecord(
|
||||
transactionID: item.transactionId,
|
||||
productID: item.productId,
|
||||
creditsGranted: item.creditsGranted,
|
||||
balanceAfter: item.balanceAfter,
|
||||
purchasedAt: purchasedAt,
|
||||
status: status
|
||||
)
|
||||
}
|
||||
return AccountCreditPurchaseHistoryPage(
|
||||
items: items,
|
||||
nextCursor: response.nextCursor
|
||||
)
|
||||
}
|
||||
|
||||
private func resource<Value: Decodable & Sendable>(
|
||||
_ type: Value.Type,
|
||||
_ resource: AccountAuthorizedResource
|
||||
) async throws -> Value {
|
||||
let data = try await apiClient.authorizedResourceData(resource)
|
||||
return try decoder.decode(type, from: data)
|
||||
}
|
||||
|
||||
private static func storeKitPurchaseDate(_ value: String) -> Date? {
|
||||
let fractional = ISO8601DateFormatter()
|
||||
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
let standard = ISO8601DateFormatter()
|
||||
standard.formatOptions = [.withInternetDateTime]
|
||||
return fractional.date(from: value) ?? standard.date(from: value)
|
||||
}
|
||||
|
||||
private func synchronizeManagedGrant() async {
|
||||
if configuration.credentialSource == .managed {
|
||||
do {
|
||||
try await ensureManagedGrant()
|
||||
} catch {
|
||||
// Keep the user's explicit billing choice. A transient grant,
|
||||
// network, or balance failure must surface as a managed-service
|
||||
// error instead of silently switching requests to a BYOK key.
|
||||
OSGDiag.log(
|
||||
"managedGrant synchronization failed "
|
||||
+ "errorCategory=\(String(reflecting: type(of: error)))",
|
||||
category: "account"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureManagedGrant() async throws {
|
||||
let expected = ManagedGatewayScopePolicy.scopes(
|
||||
engineMode: configuration.engineMode
|
||||
)
|
||||
if let existing = try await grantStore.load(),
|
||||
existing.scopes == expected,
|
||||
existing.hasUsableRefreshToken() {
|
||||
_ = try await grants.accessToken(for: .polish)
|
||||
return
|
||||
}
|
||||
await revokeManagedGrantIfPossible()
|
||||
let idempotencyKey = UUID().uuidString
|
||||
let accessToken = try await apiClient.accessTokenForAuthorizedRequest()
|
||||
do {
|
||||
_ = try await grants.createGrant(
|
||||
accountAccessToken: accessToken,
|
||||
scopes: expected,
|
||||
idempotencyKey: idempotencyKey
|
||||
)
|
||||
} catch ManagedGatewayError.invalidGrant {
|
||||
let replacement = try await apiClient.refreshAccessToken(
|
||||
afterUnauthorizedAccessToken: accessToken
|
||||
)
|
||||
_ = try await grants.createGrant(
|
||||
accountAccessToken: replacement,
|
||||
scopes: expected,
|
||||
idempotencyKey: idempotencyKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func revokeManagedGrantIfPossible() async {
|
||||
if let existing = try? await grantStore.load(),
|
||||
let grantId = UUID(uuidString: existing.grantId) {
|
||||
_ = try? await apiClient.authorizedResourceData(
|
||||
.revokeGatewayGrant(grantId)
|
||||
)
|
||||
}
|
||||
try? await grants.clearGrant()
|
||||
}
|
||||
|
||||
private func uiSession(
|
||||
_ session: OSGKeyboardHostSupport.AccountSession,
|
||||
createdAtEpochSeconds: Int64,
|
||||
displayName: String?
|
||||
) -> AccountSession {
|
||||
AccountSession(
|
||||
accountID: session.accountId,
|
||||
createdAtEpochSeconds: createdAtEpochSeconds,
|
||||
displayName: displayName
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private enum AccountDiagnostic {
|
||||
static func code(for error: Error) -> String {
|
||||
guard let error = error as? AccountAPIError else {
|
||||
return String(describing: type(of: error))
|
||||
}
|
||||
switch error {
|
||||
case .invalidRequest(let message):
|
||||
return invalidRequestCode(for: message)
|
||||
case .unauthorized:
|
||||
return "unauthorized"
|
||||
case .refreshTokenReuse:
|
||||
return "refresh-token-reuse"
|
||||
case .externalServiceUnavailable(let message):
|
||||
return unavailableServiceCode(for: message)
|
||||
case .conflict:
|
||||
return "conflict"
|
||||
case .rateLimited:
|
||||
return "rate-limited"
|
||||
case let .server(statusCode, code, _):
|
||||
return "server-\(statusCode)-\(code)"
|
||||
case .transport:
|
||||
return "transport"
|
||||
case .invalidResponse:
|
||||
return "invalid-response"
|
||||
case .decoding:
|
||||
return "decoding"
|
||||
case .secureStorage:
|
||||
return "secure-storage"
|
||||
case .sessionUnavailable:
|
||||
return "session-unavailable"
|
||||
case .appleAuthorization:
|
||||
return "apple-authorization"
|
||||
case .integrityUnavailable:
|
||||
return "integrity-unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
private static func invalidRequestCode(for message: String) -> String {
|
||||
switch message {
|
||||
case "DeviceCheck verification failed":
|
||||
return "device-check-rejected"
|
||||
case "App Attest verification failed":
|
||||
return "app-attest-rejected"
|
||||
case "identityToken exceeds the maximum length":
|
||||
return "identity-token-too-large"
|
||||
case "authorizationCode exceeds the maximum length":
|
||||
return "authorization-code-too-large"
|
||||
case "nonce exceeds the maximum length":
|
||||
return "nonce-too-large"
|
||||
default:
|
||||
return "invalid-request"
|
||||
}
|
||||
}
|
||||
|
||||
private static func unavailableServiceCode(for message: String) -> String {
|
||||
switch message {
|
||||
case "DeviceCheck is temporarily unavailable":
|
||||
return "device-check-unavailable"
|
||||
case "App Attest is temporarily unavailable":
|
||||
return "app-attest-unavailable"
|
||||
case "Apple identity verification is temporarily unavailable":
|
||||
return "apple-identity-unavailable"
|
||||
case "Apple token service is temporarily unavailable":
|
||||
return "apple-token-service-unavailable"
|
||||
default:
|
||||
return "external-service-unavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreditBalanceDTO: Decodable, Sendable {
|
||||
let balance: Int64
|
||||
let lifetimeUsed: Int64?
|
||||
}
|
||||
|
||||
private struct ReferralCodeDTO: Decodable, Sendable {
|
||||
let code: String
|
||||
let campaignId: String?
|
||||
}
|
||||
|
||||
private struct ReferralProfileDTO: Decodable, Sendable {
|
||||
let code: ReferralCodeDTO?
|
||||
}
|
||||
|
||||
private struct ReferralCampaignDTO: Decodable, Sendable {
|
||||
let id: String
|
||||
let inviterRewardCredits: Int64
|
||||
let inviteeRewardCredits: Int64
|
||||
}
|
||||
|
||||
private struct ReferralBindingDTO: Decodable, Sendable {
|
||||
let boundAt: String
|
||||
let rewardStatus: String
|
||||
}
|
||||
|
||||
private struct StoreKitProductDTO: Decodable, Sendable {
|
||||
let productId: String
|
||||
let credits: Int64
|
||||
}
|
||||
|
||||
private struct StoreKitTransactionRequestDTO: Encodable, Sendable {
|
||||
let signedTransaction: String
|
||||
}
|
||||
|
||||
private struct StoreKitPurchaseDTO: Decodable, Sendable {
|
||||
let transactionId: String
|
||||
let productId: String
|
||||
let creditsGranted: Int64
|
||||
let balanceAfter: Int64
|
||||
let replayed: Bool
|
||||
}
|
||||
|
||||
private struct StoreKitTransactionHistoryResponseDTO: Decodable, Sendable {
|
||||
let items: [StoreKitTransactionHistoryItemDTO]
|
||||
let nextCursor: String?
|
||||
}
|
||||
|
||||
private struct StoreKitTransactionHistoryItemDTO: Decodable, Sendable {
|
||||
let transactionId: String
|
||||
let productId: String
|
||||
let creditsGranted: Int64
|
||||
let balanceAfter: Int64
|
||||
let purchasedAt: String
|
||||
let status: String
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
// running a TestFlight build with a stripped entitlement, and a friendly
|
||||
// screen is much better than a crash loop.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AppGroupErrorView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
/// Deterministic host for simulator UI tests of the real assistant keyboard.
|
||||
/// It exercises gesture routing and closed UI states without requiring a
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Shared edit / selected badges for style and skill cards.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum CatalogCardChrome {
|
||||
static let badgeSize: CGFloat = 30
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// scripted `KeyboardState`. Launch with `--clipboard-demo`.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct ClipboardHistoryDemoView: View {
|
||||
private enum Layout {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
// `UsageStatsCluster` (phone stacked / iPad split). Optional `header`
|
||||
// (e.g. glass preview field) sits on the 7-day chart card.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct HomeUsageStatsSection<Header: View>: View {
|
||||
let layout: UsageStatsClusterLayout
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Google Material Icons (bundled MaterialIcons-Regular.ttf).
|
||||
// Resolves the PostScript name at runtime and falls back to SF Symbols.
|
||||
|
||||
import CoreText
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import CoreText
|
||||
|
||||
enum MaterialIconName {
|
||||
case keyboard
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// fill inside that capsule (Photos-style), not a second glass layer.
|
||||
// History + dictionary live as Home cards (not dock tabs).
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AppTab: Int, CaseIterable {
|
||||
case keyboard
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Reusable privacy / data-flow disclosure blocks for Settings and Onboarding.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
/// Card-style block for Full Access and similar permission explanations.
|
||||
struct PrivacyInfoCard: View {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Push 进 NavigationStack 子页时隐藏底部自定义 tab 栏(对齐系统 TabView 行为)。
|
||||
// MainTabView 读取 `TabBarHiddenPreferenceKey`;子页用 `hidesTabBarWhenPushed()` 声明。
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Preference
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
//
|
||||
// Home stats (chart + metric tiles) live in Shared as `UsageStatsCluster`.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Layout metrics
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// Launch with `--edit-demo`. Not shipped in Release.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct EditDemoView: View {
|
||||
private static let originalText = "明天下午三点开会讨论方案"
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
// EnginePickerSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Engine picker — Local (on-device ASR) vs
|
||||
// Cloud (ASR + optional LLM polish via the user's API).
|
||||
// Global AI service source. ASR locality is selected separately in the
|
||||
// speech-recognition provider list when the user supplies credentials.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showsManagedCloudConsent = false
|
||||
private let configurationRows: ConfigurationRows
|
||||
|
||||
init(
|
||||
@@ -22,38 +24,53 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CardSection("settings.engine.title") {
|
||||
CardSection("settings.aiService.title") {
|
||||
VStack(spacing: 0) {
|
||||
engineOptionRow(
|
||||
id: "local",
|
||||
title: AppL10n.string("settings.engine.local.title"),
|
||||
subtitle: localSubtitle
|
||||
serviceOptionRow(
|
||||
source: .managed,
|
||||
title: AppL10n.string("settings.aiService.credits.title"),
|
||||
subtitle: AppL10n.string(
|
||||
accountSession.isSignedIn
|
||||
? "settings.aiService.credits.subtitle"
|
||||
: "settings.aiService.credits.signIn"
|
||||
)
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
engineOptionRow(
|
||||
id: "cloud",
|
||||
title: AppL10n.string("settings.engine.cloud.title"),
|
||||
subtitle: AppL10n.string("settings.engine.cloud.subtitle")
|
||||
serviceOptionRow(
|
||||
source: .byok,
|
||||
title: AppL10n.string("settings.aiService.byok.title"),
|
||||
subtitle: AppL10n.string("settings.aiService.byok.subtitle")
|
||||
)
|
||||
configurationRows
|
||||
if config.credentialSource == .byok {
|
||||
configurationRows
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
AppL10n.string("settings.engine.local.legacy")
|
||||
}
|
||||
|
||||
private func engineOptionRow(
|
||||
id: String,
|
||||
private func serviceOptionRow(
|
||||
source: CredentialSource,
|
||||
title: String,
|
||||
subtitle: String
|
||||
) -> some View {
|
||||
let isSelected = config.engineMode == id
|
||||
let isSelected = config.credentialSource == source
|
||||
return Button {
|
||||
guard config.engineMode != id else { return }
|
||||
selectEngine(id)
|
||||
guard config.credentialSource != source else { return }
|
||||
selectSource(source)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -75,17 +92,41 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.aiService.\(source.rawValue)")
|
||||
.accessibilityValue(isSelected ? "selected" : "notSelected")
|
||||
.disabled(
|
||||
accountSession.operation != nil
|
||||
|| (source == .managed && !accountSession.isSignedIn)
|
||||
)
|
||||
}
|
||||
|
||||
private func selectEngine(_ id: String) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
config.engineMode = id
|
||||
if id == "cloud" {
|
||||
config.modeId = "polish"
|
||||
private func selectSource(_ source: CredentialSource) {
|
||||
switch source {
|
||||
case .managed:
|
||||
if config.hasAcknowledgedCloudSharing {
|
||||
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 {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// In-app GitHub Issues page. Reached from Settings → About.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct HelpFeedbackView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// HistoryView.swift
|
||||
// OSGKeyboard · Main App
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct HistoryView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// then engine / session status at the scroll bottom. History/dictionary
|
||||
// open via push (system back) rather than bottom-tab destinations.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum HomeRoute: Hashable {
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
// Local engine: partial transcripts stream into the text box live.
|
||||
// Cloud engine: final transcript is appended when recording stops.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct KeyboardPreviewSheet: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
|
||||
// when the user enables the keyboard in iOS Settings.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct KeyboardPreviewStub: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
// `DictationTranscriber` (nothing to download).
|
||||
// - Post-ASR polish uses the LLM provider / API key configured in Settings.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Local models group
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
// Only constructed when `AppGroup.isAvailable` so the error path never
|
||||
// touches App Group–backed singletons.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct MainAppRoot: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@@ -17,15 +17,24 @@ struct MainAppRoot: View {
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var releaseNotes = ReleaseNotesController.shared
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
@StateObject private var accountSession: AccountSessionCoordinator
|
||||
@State private var clmWarmupTask: 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 {
|
||||
Group {
|
||||
mainContent
|
||||
}
|
||||
.environment(\.locale, config.uiLanguage.swiftUILocale)
|
||||
.environmentObject(flowManager)
|
||||
.environmentObject(accountSession)
|
||||
.background {
|
||||
FlowPiPHostView { view in
|
||||
flowManager.attachPiPHostView(view)
|
||||
@@ -88,6 +97,10 @@ struct MainAppRoot: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await accountSession.restoreIfNeeded()
|
||||
config.reloadFromPersistedStorage()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
config.reloadFromPersistedStorage()
|
||||
}
|
||||
@@ -102,8 +115,20 @@ struct MainAppRoot: View {
|
||||
AIHintRefreshService.refreshIfNeeded(reason: "onboardingCompleted")
|
||||
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
|
||||
flowManager.handleScenePhase(phase)
|
||||
guard phase == .active else {
|
||||
@@ -220,6 +245,11 @@ struct MainAppRoot: View {
|
||||
}
|
||||
|
||||
private func handleIncomingURL(_ url: URL) {
|
||||
if accountSession.handleIncomingURL(url) {
|
||||
NotificationCenter.default.post(name: .osgOpenAccountDeepLink, object: nil)
|
||||
return
|
||||
}
|
||||
|
||||
guard url.scheme == "osgkeyboard" else { return }
|
||||
switch url.host {
|
||||
case "startflow":
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// Mirrors the macOS `NavigationSplitView` structure while keeping iOS tabs
|
||||
// and Flow session behaviour unchanged underneath.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct MainSplitView: View {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Shared tab destination switcher used by both the phone dock and the iPad
|
||||
// split-view detail column.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabContent: View {
|
||||
let tab: AppTab
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// MainTabView.swift
|
||||
// OSGKeyboard · Main App
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct MainTabView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@@ -29,6 +29,9 @@ struct MainTabView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
tab = .settings
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||
tab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Phone layout
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// Launch with `--whats-new-host` (+ optional `--whats-new-scenario=` / `--whats-new-lang=`).
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct NotesHostDemoView: View {
|
||||
let scenario: WhatsNewDemoScenario
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
// 5) Engine / ASR setup
|
||||
// 6) Polish LLM setup
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum OnboardingPage: Int, CaseIterable {
|
||||
@@ -235,10 +235,14 @@ struct OnboardingView: View {
|
||||
|
||||
private var pageDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<OnboardingPage.count, id: \.self) { i in
|
||||
ForEach(0..<OnboardingPage.count, id: \.self) { pageIndex in
|
||||
Capsule()
|
||||
.fill(i == config.onboardingPage ? palette.accent : palette.textTertiary.opacity(0.28))
|
||||
.frame(width: i == config.onboardingPage ? 18 : 6, height: 6)
|
||||
.fill(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -347,8 +351,8 @@ private struct OnboardingTitleBlock: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: LocalizedStringKey
|
||||
var subtitle: LocalizedStringKey? = nil
|
||||
var secondarySubtitle: LocalizedStringKey? = nil
|
||||
var subtitle: LocalizedStringKey?
|
||||
var secondarySubtitle: LocalizedStringKey?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
@@ -557,15 +561,15 @@ private struct PermissionPageLayout: View {
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let detail: LocalizedStringKey
|
||||
var preface: LocalizedStringKey? = nil
|
||||
var preface: LocalizedStringKey?
|
||||
let status: LocalizedStringKey
|
||||
let statusColor: Color
|
||||
let primaryTitle: LocalizedStringKey
|
||||
let primaryDisabled: Bool
|
||||
let onPrimary: () -> Void
|
||||
var secondaryTitle: LocalizedStringKey? = nil
|
||||
var onSecondary: (() -> Void)? = nil
|
||||
var deniedHint: LocalizedStringKey? = nil
|
||||
var secondaryTitle: LocalizedStringKey?
|
||||
var onSecondary: (() -> Void)?
|
||||
var deniedHint: LocalizedStringKey?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -783,16 +787,24 @@ private struct APISetupPage: View {
|
||||
EnginePickerSection(config: config)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if config.engineMode == "cloud" {
|
||||
if config.credentialSource == .byok {
|
||||
VStack(spacing: 0) {
|
||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
ASRSettingsCard(config: config, showsSurface: false)
|
||||
AppleASRPickerRow(config: config)
|
||||
if config.engineMode == "cloud" {
|
||||
Divider().background(palette.divider)
|
||||
ProviderPickerSection(
|
||||
config: config,
|
||||
role: .asr,
|
||||
showsSurface: false
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
ASRSettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
} else {
|
||||
Text("onboarding.api.localModels.hint")
|
||||
Text("settings.aiService.credits.subtitle")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -823,7 +835,13 @@ private struct PolishSetupPage: View {
|
||||
)
|
||||
.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")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
@@ -831,13 +849,15 @@ private struct PolishSetupPage: View {
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
APISettingsCard(config: config, showsSurface: false)
|
||||
if config.credentialSource == .byok {
|
||||
VStack(spacing: 0) {
|
||||
ProviderPickerSection(config: config, role: .polish, 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)
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// detail screen with the verbatim license text. Reached from
|
||||
// Settings → About → "Third-Party Licenses".
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct OpenSourceLicensesView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Minimal add / edit sheet for a single personal-dictionary term.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalDictionaryEntrySheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// App-Group-shared `PersonalDictionary` so changes are visible to
|
||||
// the keyboard extension on the next LLM call.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct PersonalDictionaryView: View {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Main-app editor for complete polish writing personalities. The keyboard
|
||||
// reads the selected pack from App Group storage on the next polish request.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct PolishStylesView: View {
|
||||
@@ -23,7 +23,7 @@ struct PolishStylesView: View {
|
||||
private let store = AppGroupStore()
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm),
|
||||
GridItem(.flexible(), spacing: Spacing.sm)
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Bundled privacy policy HTML. Reached from Settings → About.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct PrivacyPolicyView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// ProviderPickerSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct ProviderPickerSection: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Sheet that loads the remote release-notes HTML for the current version.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct ReleaseNotesSheet: View {
|
||||
@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.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct SettingsICloudSyncRow: View {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Shared preference picker / toggle rows used by Settings home and
|
||||
// secondary pages (General, Voice session, Daily).
|
||||
|
||||
import SwiftUI
|
||||
import Speech
|
||||
import OSGKeyboardShared
|
||||
import Speech
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - App language picker row
|
||||
|
||||
@@ -349,7 +349,7 @@ enum SettingsASRLocales {
|
||||
("zh-Hant", false),
|
||||
("en-US", false),
|
||||
("ja-JP", false),
|
||||
("ko-KR", false),
|
||||
("ko-KR", false)
|
||||
]
|
||||
|
||||
static func loadDynamic() async -> [(id: String, onDevice: Bool)] {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// OpenLess-style setting rows for provider credentials and tools. Compact iOS
|
||||
// stacks label above control; regular-width iPad keeps label/control in one row.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsProviderRow<Content: View>: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// session, general preferences, and about. Main Settings stays a
|
||||
// daily console with summary navigation rows.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// 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)
|
||||
|
||||
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 {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@@ -147,15 +186,24 @@ struct SpeechRecognitionSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
if config.engineMode == "cloud" {
|
||||
CardSection("settings.asrProvider.title") {
|
||||
CloudProviderSettingsCard {
|
||||
ProviderPickerSection(config: config, role: .asr, showsSurface: false)
|
||||
CardSection("settings.asrProvider.title") {
|
||||
CloudProviderSettingsCard {
|
||||
AppleASRPickerRow(config: config)
|
||||
|
||||
if config.engineMode == "cloud" {
|
||||
Divider().background(palette.divider)
|
||||
ProviderPickerSection(
|
||||
config: config,
|
||||
role: .asr,
|
||||
showsSurface: false
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
ASRSettingsCard(config: config, showsSurface: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
if config.engineMode == "local" {
|
||||
CardSection("settings.localEngine.title") {
|
||||
LocalModelsGroup(config: config)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Settings home: daily controls + summary navigation into secondary
|
||||
// pages for low-frequency configuration.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsPresentation {
|
||||
case tab
|
||||
@@ -18,6 +18,7 @@ enum SettingsPresentation {
|
||||
/// `hidesTabBarWhenPushed()` preferences do not leak onto the home
|
||||
/// screen (and so we avoid NavigationLink + `dismiss` freeze cycles).
|
||||
private enum SettingsRoute: Hashable {
|
||||
case account
|
||||
case speechRecognition
|
||||
case textPolish
|
||||
case general
|
||||
@@ -28,6 +29,7 @@ private enum SettingsRoute: Hashable {
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@EnvironmentObject private var accountSession: AccountSessionCoordinator
|
||||
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
|
||||
@@ -48,9 +50,7 @@ struct SettingsView: View {
|
||||
palette.background.ignoresSafeArea()
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
if presentation == .tab {
|
||||
SupportDeveloperSection(language: config.uiLanguage)
|
||||
}
|
||||
accountEntrySection
|
||||
dailySection
|
||||
transcriptionAndPolishSection
|
||||
if presentation == .tab {
|
||||
@@ -97,10 +97,17 @@ struct SettingsView: View {
|
||||
settingsDestination(for: route)
|
||||
}
|
||||
.task { await loadDynamicLocales() }
|
||||
.onAppear { consumeClipboardDeepLinkIfNeeded() }
|
||||
.onAppear {
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
Task { await accountSession.refreshAccountData() }
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenSettingsDeepLink)) { _ in
|
||||
consumeClipboardDeepLinkIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .osgOpenAccountDeepLink)) { _ in
|
||||
consumeAccountDeepLinkIfNeeded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +119,19 @@ struct SettingsView: View {
|
||||
path.append(SettingsRoute.clipboard)
|
||||
}
|
||||
|
||||
private func consumeAccountDeepLinkIfNeeded() {
|
||||
guard accountSession.consumeAccountCenterPresentation() else { return }
|
||||
if !path.isEmpty {
|
||||
path = NavigationPath()
|
||||
}
|
||||
path.append(SettingsRoute.account)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsDestination(for route: SettingsRoute) -> some View {
|
||||
switch route {
|
||||
case .account:
|
||||
AccountCenterView()
|
||||
case .speechRecognition:
|
||||
SpeechRecognitionSettingsView(config: config)
|
||||
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)
|
||||
|
||||
private var dailySection: some View {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
// The pipeline (`PolishingService`) honors `.translate` on both
|
||||
// engines when this row is visible — no more "rejected mode" toast.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct TranslationPickerRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Schema and opt-in fuzzy-pinyin settings. Changing fuzzy pairs triggers
|
||||
// host-side redeployment; the keyboard extension never compiles schemas.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct TypingInputSettingsView: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@@ -104,6 +104,17 @@
|
||||
"settings.reset.message" = "API key, model, base URL, voice history, and cloud consent will be cleared. Clipboard history is kept.";
|
||||
"settings.reset.confirm" = "Reset all settings";
|
||||
"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.local.title" = "On-device transcription";
|
||||
"settings.engine.local.ios26" = "Always on-device, no network.";
|
||||
@@ -221,7 +232,7 @@
|
||||
"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.confirm" = "Clear history";
|
||||
"settings.typingInput.title" = "Text Input";
|
||||
"settings.typingInput.title" = "Pinyin Input";
|
||||
"settings.typingInput.default.title" = "Default Input";
|
||||
"settings.typingInput.default.description" = "Used when the keyboard opens";
|
||||
"settings.typingInput.default.mode.voice" = "Voice";
|
||||
@@ -254,7 +265,7 @@
|
||||
"settings.handedness.title" = "Handedness";
|
||||
"settings.handedness.left" = "Left hand";
|
||||
"settings.handedness.right" = "Right hand";
|
||||
"settings.keyboardHaptic.title" = "Haptics";
|
||||
"settings.keyboardHaptic.title" = "Key Haptics";
|
||||
"settings.keyboardHaptic.off" = "Off";
|
||||
"settings.keyboardHaptic.light" = "Light";
|
||||
"settings.keyboardHaptic.strong" = "Strong";
|
||||
@@ -271,9 +282,9 @@
|
||||
"settings.privacy.policy" = "Privacy Policy";
|
||||
"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.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.alert.title" = "Audio leaves your 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.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" = "Cloud processing sends data off-device";
|
||||
"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.github" = "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.aiQuestionFailed" = "AI response failed. Please try again.";
|
||||
"flow.error.clipboardUnavailable" = "No clipboard text available. Turn on Clipboard History and copy the text first.";
|
||||
|
||||
/* Optional account center */
|
||||
"account.title" = "Account";
|
||||
"account.settings.section" = "Account";
|
||||
"account.settings.restoring" = "Checking…";
|
||||
"account.settings.signedOut" = "Not signed in";
|
||||
"account.settings.signedIn" = "Signed in";
|
||||
"account.settings.balance %lld" = "%lld credits";
|
||||
"account.loading.session" = "Checking account status…";
|
||||
"account.loading.center" = "Loading account…";
|
||||
"account.signedOut.title" = "Your OSG account";
|
||||
"account.signedOut.body" = "Sign in to use managed credits and referrals.";
|
||||
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
|
||||
"account.signIn.apple" = "Sign in with Apple";
|
||||
"account.signIn.hint" = "Creates or opens your optional OSG account.";
|
||||
"account.signIn.loading" = "Signing in…";
|
||||
"account.summary.section" = "Account";
|
||||
"account.summary.signedIn" = "Signed in";
|
||||
"account.summary.protected" = "Protected account";
|
||||
"account.profile.section" = "Profile";
|
||||
"account.profile.fallbackName" = "OSG User";
|
||||
"account.profile.nickname" = "Nickname";
|
||||
"account.profile.save" = "Save Nickname";
|
||||
"account.profile.editTitle" = "Edit";
|
||||
"account.profile.editMessage" = "Enter the nickname shown with your account.";
|
||||
"account.error.updateProfile" = "Could not update your nickname.";
|
||||
"account.summary.identifier" = "Account identifier";
|
||||
"account.credits.section" = "Credits";
|
||||
"account.credits.balance" = "Available credits";
|
||||
"account.credits.unit" = "Available credits";
|
||||
"account.credits.remaining" = "Remaining";
|
||||
"account.credits.used" = "Used";
|
||||
"account.credits.total" = "total credits";
|
||||
"account.credits.remainingCompact" = "%@";
|
||||
"account.credits.usedCompact" = "Used %@";
|
||||
"account.credits.totalValueCompact" = "Total %@";
|
||||
"account.credits.updating" = "Updating credits…";
|
||||
"account.credits.updatedAt" = "Updated at %@";
|
||||
"account.credits.updateFailedAt" = "Update failed · Showing data from %@";
|
||||
"account.credits.notUpdated" = "Credits have not been updated";
|
||||
"account.storekit.section" = "Add credits";
|
||||
"account.storekit.credits" = "credits";
|
||||
"account.storekit.purchase" = "Purchase";
|
||||
"account.storekit.loading" = "Loading App Store product…";
|
||||
"account.storekit.pending" = "Purchase pending approval.";
|
||||
"account.storekit.succeeded" = "%lld credits added";
|
||||
"account.storekit.noRestore" = "Credit packs are consumable and cannot be restored.";
|
||||
"account.storekit.error.productUnavailable" = "This credit pack is not available from the App Store.";
|
||||
"account.storekit.error.verification" = "The App Store could not verify this transaction.";
|
||||
"account.storekit.error.server" = "The account server could not record this purchase.";
|
||||
"account.storekit.error.session" = "Sign in again before purchasing credits.";
|
||||
"account.storekit.error.network" = "Connect to the internet and try again.";
|
||||
"account.storekit.error.conflict" = "This transaction cannot be applied to the current account.";
|
||||
"account.storekit.error.unknown" = "The purchase could not be completed.";
|
||||
"account.purchaseHistory.title" = "Purchase History";
|
||||
"account.purchaseHistory.loading" = "Loading purchase history…";
|
||||
"account.purchaseHistory.empty" = "No purchases yet";
|
||||
"account.purchaseHistory.error" = "Purchase history could not be loaded. Try again.";
|
||||
"account.purchaseHistory.loadMoreError" = "Could not load more purchases. Tap to retry";
|
||||
"account.purchaseHistory.balanceAfter" = "Balance after %@";
|
||||
"account.purchaseHistory.status.credited" = "Credited";
|
||||
"account.ledger.section" = "Credit history";
|
||||
"account.ledger.empty" = "No credit activity yet";
|
||||
"account.ledger.defaultReason" = "Credit activity";
|
||||
"account.rates.section" = "Rates";
|
||||
"account.rates.empty" = "No managed-service rates available";
|
||||
"account.rate.asr.seconds" = "%lld credit / %lld seconds";
|
||||
"account.rate.asr.milliseconds" = "%lld credit / %lld ms";
|
||||
"account.rate.llm" = "Input: %lld credit / %lld tokens · Output: %lld credit / %lld tokens";
|
||||
"account.referral.section" = "Invitations";
|
||||
"account.referral.yourCode" = "Your invitation code";
|
||||
"account.referral.notCreated" = "Not created";
|
||||
"account.rewards.title" = "Credits & Invitations";
|
||||
"account.rewards.details" = "View credits and invitation details";
|
||||
"account.referral.share" = "Share invitation";
|
||||
"account.referral.inviteAction" = "Invite friends and earn credits";
|
||||
"account.referral.rewardExplanation" = "After a friend registers through your link and completes qualifying usage, both of you can earn credits.";
|
||||
"account.referral.rewardGeneric" = "Invite a friend and earn credits";
|
||||
"account.referral.rewardPerFriend" = "Earn %@ credits for each successful invitation";
|
||||
"account.referral.qualification" = "Granted after your friend completes qualifying usage";
|
||||
"account.referral.friendRewardQualification" = "Your friend also receives %@ credits after qualifying usage";
|
||||
"account.referral.shareShort" = "Share";
|
||||
"account.referral.inviteTitle" = "Invite";
|
||||
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
|
||||
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
|
||||
"account.referral.summaryCompact" = "Rewarded %d · Pending %d";
|
||||
"account.referral.preparingLink" = "Preparing your invitation link…";
|
||||
"account.referral.createCode" = "Create invitation code";
|
||||
"account.referral.status.pending" = "Pending";
|
||||
"account.referral.status.rewarded" = "Rewarded";
|
||||
"account.referral.status.ineligible" = "Ineligible";
|
||||
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
|
||||
"account.security.section" = "Account controls";
|
||||
"account.signOut.action" = "Sign Out";
|
||||
"account.signOut.confirmTitle" = "Sign out?";
|
||||
"account.signOut.confirmMessage" = "On-device features and your own API keys will keep working.";
|
||||
"account.delete.action" = "Delete Account";
|
||||
"account.delete.confirmTitle" = "Permanently delete this account?";
|
||||
"account.delete.confirmMessage" = "Credits, invitation status, and server account data will be deleted. This cannot be undone.";
|
||||
"account.delete.continue" = "Continue to Reauthenticate";
|
||||
"account.delete.reauthenticateTitle" = "Confirm with Apple";
|
||||
"account.delete.reauthenticateBody" = "Apple reauthentication is required before OSGKeyboard can permanently delete the account.";
|
||||
"account.delete.reauthenticate" = "Continue with Apple";
|
||||
"account.delete.reauthenticateHint" = "Reauthenticates and permanently deletes your OSG account.";
|
||||
"account.delete.loading" = "Deleting account…";
|
||||
"account.retry" = "Try Again";
|
||||
"account.error.title" = "Account unavailable";
|
||||
"account.error.unavailable" = "Account services are not connected in this build.";
|
||||
"account.error.restore" = "Could not restore your account session.";
|
||||
"account.error.signIn" = "Could not sign in. Please try again.";
|
||||
"account.error.load" = "Could not load account details.";
|
||||
"account.error.invalidReferralCode" = "The server returned an invalid invitation code.";
|
||||
"account.error.createReferralCode" = "Could not create an invitation code.";
|
||||
"account.error.signOut" = "Could not sign out. Please try again.";
|
||||
"account.error.delete" = "Could not delete the account.";
|
||||
"account.error.redeemReferral" = "The invitation is saved, but could not be redeemed yet.";
|
||||
"account.error.appleAuthorization" = "Apple authorization could not be completed.";
|
||||
"account.error.managedGateway" = "Managed access could not be prepared. Check your balance and try again.";
|
||||
"account.managed.section" = "Service credentials";
|
||||
"account.managed.credentialSource" = "Credential source";
|
||||
"account.managed.byok" = "My API keys";
|
||||
"account.managed.accountCredits" = "Account credits";
|
||||
"account.managed.explanation" = "This choice is independent from local or cloud speech recognition. Account sessions remain private to the host app; the keyboard receives only a short-lived, limited grant.";
|
||||
"account.managed.preparing" = "Preparing managed access…";
|
||||
|
||||
@@ -104,6 +104,17 @@
|
||||
"settings.reset.message" = "将清空 API Key、模型、接口地址、语音历史及云端确认状态;剪贴板历史会保留。";
|
||||
"settings.reset.confirm" = "重置所有设置";
|
||||
"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.local.title" = "本地转写";
|
||||
"settings.engine.local.ios26" = "全程在手机本地,不用联网";
|
||||
@@ -221,7 +232,7 @@
|
||||
"settings.clipboard.clear.title" = "清空剪贴板历史?";
|
||||
"settings.clipboard.clear.message" = "将从本机永久删除全部剪贴板历史,且无法撤销。";
|
||||
"settings.clipboard.clear.confirm" = "清空历史";
|
||||
"settings.typingInput.title" = "文本输入";
|
||||
"settings.typingInput.title" = "拼音输入";
|
||||
"settings.typingInput.default.title" = "默认输入方式";
|
||||
"settings.typingInput.default.description" = "打开键盘时使用此方式";
|
||||
"settings.typingInput.default.mode.voice" = "语音";
|
||||
@@ -254,7 +265,7 @@
|
||||
"settings.handedness.title" = "握持偏好";
|
||||
"settings.handedness.left" = "左手";
|
||||
"settings.handedness.right" = "右手";
|
||||
"settings.keyboardHaptic.title" = "震动";
|
||||
"settings.keyboardHaptic.title" = "按键震动";
|
||||
"settings.keyboardHaptic.off" = "关";
|
||||
"settings.keyboardHaptic.light" = "轻";
|
||||
"settings.keyboardHaptic.strong" = "强";
|
||||
@@ -271,9 +282,9 @@
|
||||
"settings.privacy.policy" = "隐私政策";
|
||||
"settings.privacy.fullAccess.title" = "关于完全访问";
|
||||
"settings.privacy.fullAccess.body" = "用来调用麦克风和读取 API Key;不会读取或上传你的输入内容。";
|
||||
"settings.privacy.cloud.body" = "云端润色模式下,转写文字会发送到你配置的 API(如 OpenAI 或自建服务)。OSGKeyboard 不运营服务器,也不会把转写内容存到云端。";
|
||||
"settings.privacy.cloud.alert.title" = "音频将离开你的设备";
|
||||
"settings.privacy.cloud.alert.message" = "云端引擎会把你的语音录音上传到你配置的第三方识别服务,并把转写文字发送到其 API 进行润色,适用该服务商的隐私政策。OSGKeyboard 不会在自有服务器上存储数据。是否继续?";
|
||||
"settings.privacy.cloud.body" = "使用自备 API Key 时,云端请求会直接发送到你配置的服务商;选择 OSG 积分时,音频和文字请求会经 OSG 托管网关发送到托管服务商。";
|
||||
"settings.privacy.cloud.alert.title" = "云端处理会将数据发送到设备外";
|
||||
"settings.privacy.cloud.alert.message" = "所选云端路径会把录音和相关文字发送到设备外,用于转写、润色或 AI 回答。用户自备请求直达所选服务商;托管积分请求经 OSG 托管网关处理。";
|
||||
"settings.link.support" = "帮助与反馈";
|
||||
"settings.link.github" = "GitHub";
|
||||
"settings.support.footer" = "OSGKeyboard 为开源项目,欢迎在 GitHub 提交问题与建议。";
|
||||
@@ -669,3 +680,127 @@
|
||||
"flow.error.editLastInputFailed" = "未能完成编辑,请重试。";
|
||||
"flow.error.aiQuestionFailed" = "AI 回答失败,请重试。";
|
||||
"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" = "正在准备托管服务…";
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
// • `phase` is a real stored property (no derivation) — the previous
|
||||
// "derive from recordStream" shim locked out every press after the first.
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private final class KeyboardHostingController: UIHostingController<KeyboardSurfaceRoot> {
|
||||
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
|
||||
@@ -994,7 +994,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
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)
|
||||
hosting = host
|
||||
|
||||
@@ -110,7 +110,11 @@ final class AIKeyboardCoordinator {
|
||||
if case .materialUnavailable = resolution {
|
||||
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.
|
||||
@@ -121,7 +125,7 @@ final class AIKeyboardCoordinator {
|
||||
for: card,
|
||||
clipboardText: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
|
||||
)
|
||||
submitResolvedPrompt(resolution)
|
||||
submitResolvedPrompt(resolution, taskKind: .aiQuestion)
|
||||
}
|
||||
|
||||
private var canAcceptIdleSubmit: Bool {
|
||||
@@ -136,6 +140,7 @@ final class AIKeyboardCoordinator {
|
||||
|
||||
private func submitResolvedPrompt(
|
||||
_ resolution: AIClipboardPrompt.Resolution,
|
||||
taskKind: ManagedGatewayTaskKind,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) {
|
||||
guard case .ready(let prompt) = resolution else {
|
||||
@@ -154,6 +159,7 @@ final class AIKeyboardCoordinator {
|
||||
let disposition = flow.submitAIQuestion(
|
||||
text: prompt,
|
||||
conversationID: conversationID,
|
||||
taskKind: taskKind,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
if case .rejected(let rejection) = disposition {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// (changeCount-driven). Writes accepted text into ClipboardHistoryStore.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
protocol ClipboardPasteboardProviding: AnyObject {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Flow session start, recording, watchdogs, and result delivery handling.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class KeyboardFlowCoordinator {
|
||||
@@ -681,6 +681,7 @@ final class KeyboardFlowCoordinator {
|
||||
func submitAIQuestion(
|
||||
text: String,
|
||||
conversationID: UUID,
|
||||
taskKind: ManagedGatewayTaskKind = .aiQuestion,
|
||||
thinkingEnabled: Bool? = nil
|
||||
) -> FlowUtteranceStartDisposition {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -689,6 +690,7 @@ final class KeyboardFlowCoordinator {
|
||||
.aiQuestion(
|
||||
conversationID: conversationID,
|
||||
prefilledQuestion: trimmed,
|
||||
taskKind: taskKind,
|
||||
thinkingEnabled: thinkingEnabled
|
||||
)
|
||||
)
|
||||
@@ -1191,6 +1193,7 @@ final class KeyboardFlowCoordinator {
|
||||
sourceHistoryEntryID: request.sourceHistoryEntryID,
|
||||
sourceHistoryEntryRevision: request.sourceHistoryEntryRevision,
|
||||
aiConversationID: request.aiConversationID,
|
||||
aiTaskKind: request.aiTaskKind,
|
||||
startDeadlineAt: action == .startRecording ? currentStartDeadlineAt : nil,
|
||||
processingDeadlineAt: action == .stopRecording && request.isEdit
|
||||
? Date().timeIntervalSince1970
|
||||
@@ -1898,6 +1901,7 @@ final class KeyboardFlowCoordinator {
|
||||
utteranceMode: .aiQuestion,
|
||||
aiConversationID: currentUtteranceRequest?.aiConversationID,
|
||||
aiQuestionText: text,
|
||||
aiTaskKind: currentUtteranceRequest?.aiTaskKind,
|
||||
aiThinkingEnabled: currentUtteranceRequest?.aiThinkingEnabled,
|
||||
startDeadlineAt: currentStartDeadlineAt
|
||||
)
|
||||
@@ -2034,7 +2038,7 @@ final class KeyboardFlowCoordinator {
|
||||
onAIStreamingAnswer(result.text ?? "", result.utteranceId)
|
||||
return
|
||||
}
|
||||
if (result.status == .partial || result.status == .rawReady),
|
||||
if result.status == .partial || result.status == .rawReady,
|
||||
let partial = result.text,
|
||||
!partial.isEmpty {
|
||||
if result.resolvedUtteranceMode == .aiQuestion,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// without re-running LLM polish in the extension. Also tracks the last
|
||||
// voice insertion so the undo button can roll it back safely.
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
@MainActor
|
||||
final class KeyboardTextInserter {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// UIKit grid for the expanded Chinese candidate panel. UILabel cells +
|
||||
// UICollectionView recycling stay far cheaper than hundreds of SwiftUI Buttons.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct CandidateExpandGridView: UIViewRepresentable {
|
||||
var candidates: [TypingCandidate]
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Switches between voice and typing surfaces driven by KeyboardState.surface.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct KeyboardSurfaceRoot: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// overlapping fingers commit in press order, delete repeats on down,
|
||||
// Shift holds while that finger owns it.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
var layout: TypingKeyLayout
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Typing surface: candidate bar + QWERTY / 123 / symbols.
|
||||
// Top-leading control returns to the voice surface.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum TypingLayoutMetrics {
|
||||
// Size decisions live in `TypingSurfaceMetrics` (Shared) so the UIKit
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// duplicate Localizable.strings variant groups into the main app target only.
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum ExtL10n {
|
||||
private static let table = "Keyboard"
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Role-based typing haptics (no screen-position mapping). Intensity comes
|
||||
// from Settings → General → Haptics (off / light / strong).
|
||||
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
/// Key roles drive distinct Taptic styles — closer to a real keyboard than
|
||||
/// a single buzz for every tap.
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Plays the built-in iOS keyboard click sounds so the custom bottom-row
|
||||
// keys (space / return / delete) sound identical to the stock keyboard.
|
||||
|
||||
import UIKit
|
||||
import AudioToolbox
|
||||
import UIKit
|
||||
|
||||
/// 让键盘扩展支持系统点击音。`UIDevice.playInputClick()` 只有在「某个
|
||||
/// 可见的输入视图遵循本协议且返回 true」时才会发声。键盘扩展的根视图
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Unified assistant surface: tap for ordinary dictation, hold for AI, and use
|
||||
// the same idle layout for hotwords, clipboard skills, editing, and Send.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct AIKeyboardView: View {
|
||||
private enum Layout {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Clipboard suggestion strip, enable-guide sheet, and history panel.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Suggestion strip (Doubao-style)
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
// a transient `UIEvent`. SwiftUI draws the shared native key chrome while a
|
||||
// transparent `UIButton` owns touch delivery.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct GlobeInputModeButton: UIViewRepresentable {
|
||||
@ObservedObject var state: KeyboardState
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Shared top-right input switcher used by both voice and typing surfaces.
|
||||
// The same footprint is replaced by candidates while Chinese is composing.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
private struct KeyboardTabSelectionNamespaceKey: EnvironmentKey {
|
||||
static let defaultValue: Namespace.ID? = nil
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// LastInputEditView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct LastInputEditView: View {
|
||||
private enum Layout {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Bottom-row action keys: repeating delete, space, and return.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Layout metrics
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// CandidatePanelExpandTests.swift
|
||||
// OSGKeyboard · Ext unit tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class CandidatePanelExpandTests: XCTestCase {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// and one keyboard presentation; clipboard history remains independently usable.
|
||||
|
||||
import Combine
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class ClipboardSuggestionLifecycleTests: XCTestCase {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// EditHintSchedulerTests.swift
|
||||
// OSGKeyboard · Keyboard Extension Tests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
final class EditHintSchedulerTests: XCTestCase {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Lexicon / accent / suggestion ranking for the English typing path.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class EnglishTypingTests: XCTestCase {
|
||||
func testLexiconLoadsAndCompletesPrefix() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// FinalChunkRecoveryTests.swift
|
||||
// OSGKeyboardExtTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class FinalChunkRecoveryTests: XCTestCase {
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//
|
||||
// Phase 1 / 3: gap fill, nearest-center, intent offset, edge expansion.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class KeyHitTestingTests: XCTestCase {
|
||||
private func makePairTargets(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user