feat(app): add crash visibility, memory relief, and clipboard reply work
Squashes the working tree that had accumulated on cloud-agent. Two strands of work, kept in one commit at the maintainer's request. Crash and stability (this session): - Subscribe to MetricKit so crash, hang, CPU, and disk-write diagnostics are recorded on device. Keyboard-extension memory kills are the most likely way a user loses the keyboard mid-sentence and iOS does not classify them as crashes, so they never reach App Store Connect. - Upload those reports to POST /v1/analytics/diagnostics from internal builds only (local Debug and TestFlight), reusing the analytics installation identifier and skipping entirely when analytics are off. App Store builds keep everything on device. - Shed the Chinese engine, English lexicon, and in-flight pipeline work at the 40 MiB and 48 MiB budget thresholds instead of waiting for a system memory warning that jetsam often preempts. - Install audio taps with the input node's live format through one shared adaptive resampler, so an audio-route change mid-recording no longer raises an uncatchable format-mismatch exception. Covers the dictation preview and the Mac recorder, which still used the pattern the Flow capture path had already moved away from. - Harden the librime bridge: never pass a nil string across a nonnull boundary when a user dictionary holds malformed UTF-8, null-check the engine API vector, and distrust a candidate count without its array. - Guard the expanded candidate grid against a list that shrinks while the collection view is still laying out against the previous count. - Resolve localized text without the App Group so the screen shown when that container is missing renders its message instead of crashing on launch, which is the one situation it exists to handle. - Gate CI on the App Group and Keychain wiring that the keyboard extension hard-depends on, with a self-test proving the check fails. Clipboard, reply, and semantics (pre-existing working tree): - Unify the reply center, refresh keyboard AI features, and add native event and reminder exporters. - Expand clipboard semantic routing with authored corpora and a product-blind holdout, plus the scripts that build and validate them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -102,10 +102,14 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSCalendarsFullAccessUsageDescription</key>
|
||||
<string>OSGKeyboard adds calendar events only when you choose Extract Events for copied text.</string>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
|
||||
<key>NSRemindersFullAccessUsageDescription</key>
|
||||
<string>OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.</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>
|
||||
|
||||
@@ -29,6 +29,12 @@ struct OSGKeyboardApp: App {
|
||||
// near ~150 MB RSS in Debug; compiling CLM during onboarding races the
|
||||
// keyboard extension and gets the host jetsammed (signal 9). Warmup
|
||||
// runs from MainAppRoot only after onboarding completes.
|
||||
// Subscribe before the first frame: MetricKit delivers the previous
|
||||
// run's crash / hang diagnostics shortly after launch, and a late
|
||||
// subscriber simply misses that delivery. This is the only visibility
|
||||
// we have into keyboard-extension memory kills, which never surface as
|
||||
// crashes in App Store Connect.
|
||||
CrashDiagnosticsService.shared.start()
|
||||
OSGDiag.log("OSGKeyboardApp.init \(OSGDiag.memoryTag())", category: "flow")
|
||||
}
|
||||
|
||||
@@ -48,13 +54,16 @@ struct OSGKeyboardApp: App {
|
||||
AIKeyboardDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
|
||||
AIClipboardSkillLayoutDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--smart-reply-demo") {
|
||||
SmartReplyDemoView()
|
||||
} 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("--polish-styles-screenshot") {
|
||||
PolishStylesScreenshotHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot")
|
||||
|| ProcessInfo.processInfo.arguments.contains("--home-calendar-screenshot") {
|
||||
HomeDictionaryScreenshotHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--managed-consent-ui-test") {
|
||||
ManagedCloudConsentUITestHarness()
|
||||
|
||||
@@ -112,6 +112,40 @@
|
||||
<string>NSPrivacyCollectedDataTypePurposeAnalytics</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<!-- MetricKit crash reports: exception type, signal, termination
|
||||
reason, binary names and a truncated call stack. Captured on
|
||||
every build, but uploaded ONLY from internal Debug / TestFlight
|
||||
builds (InternalDiagnosticsUploader); App Store builds keep them
|
||||
on device for manual export. Declared unconditionally because
|
||||
the manifest has no beta-only qualifier and under-declaring is
|
||||
the review risk. Linked because the upload carries the account
|
||||
bearer when the tester is signed in. -->
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypeCrashData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<!-- MetricKit hang / CPU / disk-write diagnostics. Same capture and
|
||||
upload rules as the crash data above. -->
|
||||
<key>NSPrivacyCollectedDataType</key>
|
||||
<string>NSPrivacyCollectedDataTypePerformanceData</string>
|
||||
<key>NSPrivacyCollectedDataTypeLinked</key>
|
||||
<true/>
|
||||
<key>NSPrivacyCollectedDataTypeTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedDataTypePurposes</key>
|
||||
<array>
|
||||
<string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<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>Anonymous onboarding AI trial</strong> — before you sign in, onboarding can issue a short-lived, feature-limited credential after Apple App Attest verifies a genuine app installation. The four fixed lessons may send one voice transcript, an explicitly copied lesson sample for translation and reply, and one transcribed AI question through <code>account.osglab.com</code>. This flow does not create an account, process an Apple identity token, use credits, or upload raw voice audio. The copied lesson sample is temporary and does not enable or populate clipboard history. The service keeps only pseudonymous integrity, request-state, feature, and metering records needed to enforce the one-use limits and investigate abuse; it does not retain request or response bodies.</li>
|
||||
<li><strong>AI suggestions and OSGKeyboard web pages</strong> — the main app may request public suggestion titles from <code>key.osglab.com</code>; no voice, transcript, clipboard body, API key, or ordinary keystroke content is included. Opening the update page requests public content from <code>download.osglab.com</code>. Those hosts may process ordinary connection metadata such as IP address and request time.</li>
|
||||
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders, Calendar, and Notes exports pass the generated result on-device to an Apple Shortcut you install. Navigation opens an address or route in Apple Maps, Amap, or Baidu Maps; the selected provider's policy applies.</li>
|
||||
<li><strong>AI Agent skills, Shortcuts, and maps</strong> — when you deliberately run a skill, the selected or copied text and skill prompt are sent to your configured LLM provider. Reminders and Calendar exports write the generated items directly through Apple's EventKit on this device, after iOS asks you for permission; Calendar uses write-only access, so the app can add events but never reads your existing ones. 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>
|
||||
@@ -47,7 +47,7 @@
|
||||
<h2>What we do not collect</h2>
|
||||
<ul>
|
||||
<li>We do <strong>not</strong> log or upload ordinary keystrokes.</li>
|
||||
<li>We do <strong>not</strong> use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA.</li>
|
||||
<li>We do <strong>not</strong> use third-party analytics, crash-reporting, advertising SDKs, ATT, or IDFA. OSGKeyboard records Apple <strong>MetricKit</strong> crash and hang diagnostics on your device so you can export them from Settings ▸ Diagnostics; App Store builds never upload them. Beta builds distributed through TestFlight additionally upload these diagnostics to <code>account.osglab.com</code>, and you can turn that off in the same screen. The reports contain crash metadata only — exception type, signal, termination reason, binary names and a truncated call stack — never audio, transcripts, clipboard content, prompts, or API keys.</li>
|
||||
<li>We do <strong>not</strong> sell personal data.</li>
|
||||
</ul>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
<li><strong>AI 模式问题</strong> — 在 AI 键盘模式下,语音转写后的问题文字或你点选的建议会发送到当前选择的 AI 服务。用户自备请求直达所选服务商;托管积分请求经 <code>account.osglab.com</code> 处理。所选服务支持联网搜索时,可能检索公开网页结果回答时效性问题。</li>
|
||||
<li><strong>匿名首次体验</strong> — 登录前,开机引导可在 Apple App Attest 验证正版 App 后签发短时、限功能的匿名凭证。四项固定教学会经 <code>account.osglab.com</code> 分别发送一次语音转写文字、由你明确复制的教学示例(用于翻译与回复),以及一次语音转写后的 AI 问题;不会创建账号、处理 Apple 身份令牌、使用积分或上传原始语音。教学示例仅短时使用,不会开启或写入剪贴板历史。服务端只保留执行一次性限制和调查滥用所需的匿名完整性、请求状态、功能类型及计量记录,不保留请求或回答正文。</li>
|
||||
<li><strong>AI 建议与 OSGKeyboard 网页</strong> — 主 App 可能从 <code>key.osglab.com</code> 请求公开建议标题;请求不包含语音、转写、剪贴板正文、API Key 或普通击键内容。打开更新页时会从 <code>download.osglab.com</code> 请求公开内容。相关主机可能处理 IP 地址、请求时间等普通连接元数据。</li>
|
||||
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项、日历与备忘录导出会在设备端把生成结果交给你安装的 Apple 快捷指令;导航会在 Apple 地图、高德地图或百度地图中打开地址或路线。</li>
|
||||
<li><strong>AI Agent 技能、快捷指令与地图</strong> — 仅在你主动运行技能时,所选或复制文字及技能提示词才会发送到你配置的 LLM。提醒事项与日历导出会在本机经 iOS 授权后通过 Apple EventKit 直接写入,日历仅使用「仅写入」权限,只能新增日程、无法读取你已有的日程;备忘录导出会在设备端把生成结果交给你安装的 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>
|
||||
@@ -100,7 +100,7 @@
|
||||
<h2>我们不收集的内容</h2>
|
||||
<ul>
|
||||
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
|
||||
<li>我们<strong>不会</strong>集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。</li>
|
||||
<li>我们<strong>不会</strong>集成第三方分析、崩溃上报或广告 SDK,也不使用 ATT 或 IDFA。OSGKeyboard 会在你的设备上记录 Apple <strong>MetricKit</strong> 崩溃与卡顿诊断,供你在「设置 ▸ 诊断」中导出;App Store 版本不会上传。通过 TestFlight 分发的内测版本会额外将这些诊断上传至 <code>account.osglab.com</code>,可在同一页面关闭。报告只包含崩溃元数据——异常类型、信号、终止原因、二进制名称与截断的调用栈——不包含录音、转写文本、剪贴板内容、提示词或 API 密钥。</li>
|
||||
<li>我们<strong>不会</strong>出售个人数据。</li>
|
||||
</ul>
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,8 +8,16 @@ import OSGKeyboardShared
|
||||
import UIKit
|
||||
|
||||
enum AIAgentShortcutRunner {
|
||||
/// `onReminders` / `onEvents` receive the parsed payload for the built-in
|
||||
/// native Reminders / Calendar skills so the caller can drive EventKit +
|
||||
/// present UI. `onEvents` gets the canonical `start|end|title|location`
|
||||
/// lines (decoded host-side by `AIEventExporter`). Other exports still open
|
||||
/// their companion Shortcut here.
|
||||
@MainActor
|
||||
static func runPendingIfNeeded() {
|
||||
static func runPendingIfNeeded(
|
||||
onReminders: ([String]) -> Void = { _ in },
|
||||
onEvents: ([String]) -> Void = { _ in }
|
||||
) {
|
||||
AIAgentShortcutRun.trace("host.runPending begin")
|
||||
let store = AppGroupStore()
|
||||
guard let payload = store.consumePendingShortcutRun() else { return }
|
||||
@@ -23,6 +31,16 @@ enum AIAgentShortcutRunner {
|
||||
openMap(for: payload)
|
||||
return
|
||||
}
|
||||
if skill.id == AIClipboardSkillCatalog.extractTodosID {
|
||||
AIAgentShortcutRun.trace("host.runPending nativeReminders items=\(payload.titles.count)")
|
||||
onReminders(payload.titles)
|
||||
return
|
||||
}
|
||||
if skill.id == AIClipboardSkillCatalog.extractEventsID {
|
||||
AIAgentShortcutRun.trace("host.runPending nativeCalendar items=\(payload.titles.count)")
|
||||
onEvents(payload.titles)
|
||||
return
|
||||
}
|
||||
guard let name = skill.shortcutName else {
|
||||
AIAgentShortcutRun.trace(
|
||||
"host.runPending skip missingShortcut skill=\(payload.skillID)"
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// AIEventExporter.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Native Calendar export for the built-in "extract events" skill. Replaces the
|
||||
// old OSGExtractEvents companion Shortcut: the host requests Calendar access at
|
||||
// first use (EventKit shows the system prompt) and writes one event per parsed
|
||||
// line into the default calendar. No Shortcut install required.
|
||||
//
|
||||
// The keyboard already encodes each event as a canonical
|
||||
// `start|end|title|location` line (see AIEventExtraction); this decodes those
|
||||
// back into EKEvents so the parsing stays in one place.
|
||||
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum AIEventExporter {
|
||||
enum Outcome: Equatable {
|
||||
/// At least one event was written. `created` may be < requested if a few
|
||||
/// individual saves failed, but the run still succeeded overall.
|
||||
case created(count: Int)
|
||||
/// The user has denied or restricted Calendar access. Offer Settings.
|
||||
case accessDenied
|
||||
/// Access was granted but no event could be written (no default calendar,
|
||||
/// nothing decoded, or every save threw). Distinct from `accessDenied` so
|
||||
/// the UI can tell a permission problem from an empty result.
|
||||
case failed
|
||||
}
|
||||
|
||||
/// Requests access (prompting on first use) and appends the decoded events to
|
||||
/// the default calendar. Safe to call from the main actor; EventKit does its
|
||||
/// own threading and `commit` is synchronous.
|
||||
static func add(lines: [String]) async -> Outcome {
|
||||
let events = lines.compactMap { AIEventExtraction.decode($0) }
|
||||
guard !events.isEmpty else {
|
||||
AIAgentShortcutRun.trace("host.calendar noDecodableLines given=\(lines.count)")
|
||||
return .failed
|
||||
}
|
||||
|
||||
let store = EKEventStore()
|
||||
let granted = await requestAccess(store)
|
||||
guard granted else {
|
||||
AIAgentShortcutRun.trace("host.calendar accessDenied")
|
||||
return .accessDenied
|
||||
}
|
||||
guard let calendar = store.defaultCalendarForNewEvents else {
|
||||
AIAgentShortcutRun.trace("host.calendar noDefaultCalendar")
|
||||
return .failed
|
||||
}
|
||||
|
||||
var created = 0
|
||||
for parsed in events {
|
||||
let event = EKEvent(eventStore: store)
|
||||
event.calendar = calendar
|
||||
event.title = parsed.title
|
||||
if let location = parsed.location { event.location = location }
|
||||
event.isAllDay = parsed.isAllDay
|
||||
event.startDate = parsed.start
|
||||
// All-day events have no meaningful end time; EventKit wants a same-day
|
||||
// end, so reuse the start. Timed events always carry a resolved end.
|
||||
event.endDate = parsed.end ?? parsed.start
|
||||
do {
|
||||
// Batch the writes: stage without committing, then commit once.
|
||||
try store.save(event, span: .thisEvent, commit: false)
|
||||
created += 1
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.calendar saveFailed \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
guard created > 0 else {
|
||||
store.reset()
|
||||
return .failed
|
||||
}
|
||||
do {
|
||||
try store.commit()
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.calendar commitFailed \(error.localizedDescription)")
|
||||
return .failed
|
||||
}
|
||||
AIAgentShortcutRun.trace("host.calendar created=\(created) of=\(events.count)")
|
||||
return .created(count: created)
|
||||
}
|
||||
|
||||
/// Write-only is deliberate: this exporter only ever creates events and
|
||||
/// never reads the user's calendar, so asking for full access would request
|
||||
/// more than the feature needs. `defaultCalendarForNewEvents` remains
|
||||
/// available under write-only access.
|
||||
private static func requestAccess(_ store: EKEventStore) async -> Bool {
|
||||
do {
|
||||
return try await store.requestWriteOnlyAccessToEvents()
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.calendar requestFailed \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// AIReminderExporter.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Native Reminders export for the built-in "extract todos" skill. Replaces the
|
||||
// old companion Shortcut: the host requests Reminders access at first use
|
||||
// (EventKit shows the system prompt) and writes one reminder per parsed title
|
||||
// into the default list. No Shortcut install required.
|
||||
|
||||
import EventKit
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum AIReminderExporter {
|
||||
enum Outcome: Equatable {
|
||||
/// At least one reminder was written. `created` may be < requested if a
|
||||
/// few individual saves failed, but the run still succeeded overall.
|
||||
case created(count: Int)
|
||||
/// The user has denied or restricted Reminders access. Offer Settings.
|
||||
case accessDenied
|
||||
/// Access was granted but no reminder could be written (no default list
|
||||
/// or every save threw). Distinct from `accessDenied` so the UI can tell
|
||||
/// a permission problem from an empty result.
|
||||
case failed
|
||||
}
|
||||
|
||||
/// Requests access (prompting on first use) and appends `titles` to the
|
||||
/// default reminders list. Safe to call from the main actor; EventKit does
|
||||
/// its own threading and `commit` is synchronous.
|
||||
static func add(titles: [String]) async -> Outcome {
|
||||
let cleaned = titles
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !cleaned.isEmpty else { return .failed }
|
||||
|
||||
let store = EKEventStore()
|
||||
let granted = await requestAccess(store)
|
||||
guard granted else {
|
||||
AIAgentShortcutRun.trace("host.reminders accessDenied")
|
||||
return .accessDenied
|
||||
}
|
||||
guard let calendar = store.defaultCalendarForNewReminders() else {
|
||||
AIAgentShortcutRun.trace("host.reminders noDefaultList")
|
||||
return .failed
|
||||
}
|
||||
|
||||
var created = 0
|
||||
for title in cleaned {
|
||||
let reminder = EKReminder(eventStore: store)
|
||||
reminder.calendar = calendar
|
||||
reminder.title = title
|
||||
do {
|
||||
// Batch the writes: stage without committing, then commit once.
|
||||
try store.save(reminder, commit: false)
|
||||
created += 1
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.reminders saveFailed \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
guard created > 0 else {
|
||||
store.reset()
|
||||
return .failed
|
||||
}
|
||||
do {
|
||||
try store.commit()
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.reminders commitFailed \(error.localizedDescription)")
|
||||
return .failed
|
||||
}
|
||||
AIAgentShortcutRun.trace("host.reminders created=\(created) of=\(cleaned.count)")
|
||||
return .created(count: created)
|
||||
}
|
||||
|
||||
private static func requestAccess(_ store: EKEventStore) async -> Bool {
|
||||
do {
|
||||
return try await store.requestFullAccessToReminders()
|
||||
} catch {
|
||||
AIAgentShortcutRun.trace("host.reminders requestFailed \(error.localizedDescription)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,17 @@ final class AnalyticsHostService: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Installation identifier shared with the analytics pipeline, or `nil`
|
||||
/// when analytics are switched off.
|
||||
///
|
||||
/// Diagnostics upload deliberately reuses it instead of minting a second
|
||||
/// device identifier: a tester who turned analytics off has said they do
|
||||
/// not want a stable id leaving the device, and that answer should hold
|
||||
/// here too. `nil` simply means the crash report stays on device.
|
||||
func installationIdentifierIfEnabled() async -> UUID? {
|
||||
await runtime.repository.installationIdentifierIfEnabled()
|
||||
}
|
||||
|
||||
func refreshEnabledState() {
|
||||
Task {
|
||||
let current = await runtime.isEnabled()
|
||||
|
||||
@@ -86,10 +86,53 @@ enum AppPermissions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Undocumented Settings deep link that lands on General → Keyboard, the
|
||||
/// page where the user adds the keyboard and grants Full Access.
|
||||
///
|
||||
/// `prefs:`-family URLs are private. App Review generally rejects them,
|
||||
/// with custom-keyboard extensions using this specific keyboard path as
|
||||
/// the long-standing exception — it is what shipping third-party keyboards
|
||||
/// rely on. Apple can break or start rejecting it at any time, so every
|
||||
/// call falls back to the public `openSettingsURLString`, and setting this
|
||||
/// to `false` removes the private scheme from the binary's behaviour
|
||||
/// entirely without touching any call site.
|
||||
static let usesPrivateKeyboardSettingsDeepLink = false
|
||||
|
||||
private static let keyboardSettingsDeepLink = "App-Prefs:root=General&path=Keyboard"
|
||||
|
||||
/// Opens the keyboard section of Settings when iOS honours the deep link,
|
||||
/// otherwise the app's own Settings page. `completion` reports whether any
|
||||
/// of the attempts was accepted, so the caller can fall back to written
|
||||
/// steps when none was.
|
||||
@MainActor
|
||||
static func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
static func openKeyboardSettings(completion: ((Bool) -> Void)? = nil) {
|
||||
guard usesPrivateKeyboardSettingsDeepLink,
|
||||
let deepLink = URL(string: keyboardSettingsDeepLink) else {
|
||||
openSystemSettings(completion: completion)
|
||||
return
|
||||
}
|
||||
UIApplication.shared.open(deepLink) { opened in
|
||||
guard opened else {
|
||||
openSystemSettings(completion: completion)
|
||||
return
|
||||
}
|
||||
completion?(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the app's own page in Settings. iOS exposes no *public* URL that
|
||||
/// reaches General → Keyboard → Keyboards, so callers must still guide the
|
||||
/// user through the remaining taps. `completion` reports whether iOS
|
||||
/// actually accepted the open so the caller can fall back to written steps.
|
||||
@MainActor
|
||||
static func openSystemSettings(completion: ((Bool) -> Void)? = nil) {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else {
|
||||
completion?(false)
|
||||
return
|
||||
}
|
||||
UIApplication.shared.open(url) { opened in
|
||||
completion?(opened)
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs an explicit direct read so iOS can present paste authorization
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
// CrashDiagnosticsService.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// The app previously had NO crash visibility of any kind: no third-party SDK,
|
||||
// no MetricKit subscriber, no uncaught-exception handler. Two consequences
|
||||
// mattered in practice:
|
||||
//
|
||||
// 1. Keyboard-extension jetsams (the ~60 MiB memory ceiling) are the single
|
||||
// most likely way a user loses the keyboard mid-sentence, and they are
|
||||
// NOT reported as crashes in App Store Connect — they never reached us
|
||||
// at all.
|
||||
// 2. Every crash report had to come from a user manually describing it.
|
||||
//
|
||||
// MetricKit closes both gaps without a third-party SDK and without shipping
|
||||
// user content anywhere: iOS hands the containing app diagnostics for itself
|
||||
// AND its extensions, on next launch, already anonymised. Memory kills arrive
|
||||
// here as crash diagnostics with an `EXC_RESOURCE` exception type.
|
||||
//
|
||||
// Reports are written into the SAME App Group diagnostics directory that Flow
|
||||
// startup failures use, so Settings ▸ Diagnostics lists, exports and clears
|
||||
// them with the existing UI, and the existing retention/size budget applies.
|
||||
//
|
||||
// On App Store builds they stay on device, full stop. On INTERNAL builds (local
|
||||
// Debug and TestFlight) `InternalDiagnosticsUploader` also sends them to the
|
||||
// account service — see that file for why the split exists.
|
||||
|
||||
import Foundation
|
||||
import MetricKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
/// Subscribes to MetricKit and turns crash / hang diagnostics into local,
|
||||
/// exportable reports. Install once, at app launch.
|
||||
public final class CrashDiagnosticsService: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
|
||||
public static let shared = CrashDiagnosticsService()
|
||||
|
||||
/// Cap on the serialized call stack kept per diagnostic. Full trees run to
|
||||
/// hundreds of KB and the diagnostics directory has a 4 MiB total budget
|
||||
/// shared with Flow reports; the top frames are what identifies a crash.
|
||||
private static let maxCallStackDigestLength = 4_000
|
||||
private static let maxTopFrames = 12
|
||||
/// A crash loop can put a whole day of repeats in one payload. Keeping the
|
||||
/// newest few per kind stops it from evicting every other report.
|
||||
private static let maxDiagnosticsPerKind = 5
|
||||
/// Matches the account service's `terminationReason` column.
|
||||
static let maxTerminationReasonLength = 255
|
||||
|
||||
private let makeStore: @Sendable (FlowDiagnosticsProcess) -> FlowFailureLogStore
|
||||
private let lock = NSLock()
|
||||
private var isSubscribed = false
|
||||
private var storesByProcess: [FlowDiagnosticsProcess: FlowFailureLogStore] = [:]
|
||||
|
||||
public init(
|
||||
makeStore: @escaping @Sendable (FlowDiagnosticsProcess) -> FlowFailureLogStore = { process in
|
||||
// A fresh store per process kind: `FlowFailureLogStore.shared`
|
||||
// carries THIS session's live breadcrumbs, which have nothing to do
|
||||
// with a crash that happened before the last launch. An empty
|
||||
// window is the honest one.
|
||||
FlowFailureLogStore(process: process)
|
||||
}
|
||||
) {
|
||||
self.makeStore = makeStore
|
||||
super.init()
|
||||
}
|
||||
|
||||
/// Idempotent — safe to call from every launch path.
|
||||
public func start() {
|
||||
let shouldSubscribe = lock.withLock {
|
||||
guard !isSubscribed else { return false }
|
||||
isSubscribed = true
|
||||
return true
|
||||
}
|
||||
guard shouldSubscribe else { return }
|
||||
MXMetricManager.shared.add(self)
|
||||
OSGDiag.log("crashDiagnostics subscribed", category: "diagnostics")
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
let shouldRemove = lock.withLock {
|
||||
guard isSubscribed else { return false }
|
||||
isSubscribed = false
|
||||
return true
|
||||
}
|
||||
guard shouldRemove else { return }
|
||||
MXMetricManager.shared.remove(self)
|
||||
}
|
||||
|
||||
// MARK: - MXMetricManagerSubscriber
|
||||
|
||||
/// Daily aggregate metrics (launch time, hang rate, memory). Not used yet —
|
||||
/// diagnostics are what turn "the keyboard disappeared" into evidence.
|
||||
public func didReceive(_ payloads: [MXMetricPayload]) {}
|
||||
|
||||
public func didReceive(_ payloads: [MXDiagnosticPayload]) {
|
||||
var storedAny = false
|
||||
for payload in payloads {
|
||||
storedAny = persist(payload) || storedAny
|
||||
}
|
||||
guard storedAny else { return }
|
||||
// Internal builds only; a no-op elsewhere. Detached because MetricKit
|
||||
// calls back on its own queue and must not be made to wait on network.
|
||||
Task.detached(priority: .utility) {
|
||||
await InternalDiagnosticsUploader.shared.uploadPendingReports()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
@discardableResult
|
||||
private func persist(_ payload: MXDiagnosticPayload) -> Bool {
|
||||
// `ISO8601FormatStyle` is a Sendable value type; a shared
|
||||
// `ISO8601DateFormatter` static would not be, and MetricKit calls back
|
||||
// on an unspecified queue.
|
||||
let window = [
|
||||
"windowBegin": payload.timeStampBegin.formatted(.iso8601),
|
||||
"windowEnd": payload.timeStampEnd.formatted(.iso8601)
|
||||
]
|
||||
|
||||
var storedAny = false
|
||||
|
||||
for diagnostic in (payload.crashDiagnostics ?? []).prefix(Self.maxDiagnosticsPerKind) {
|
||||
var context = window
|
||||
context["exceptionType"] = Self.identifier(diagnostic.exceptionType?.stringValue)
|
||||
context["exceptionCode"] = Self.identifier(diagnostic.exceptionCode?.stringValue)
|
||||
context["signal"] = Self.identifier(diagnostic.signal?.stringValue)
|
||||
context["terminationReason"] = diagnostic.terminationReason.map {
|
||||
String($0.prefix(Self.maxTerminationReasonLength))
|
||||
}
|
||||
context["virtualMemoryRegionInfo"] = diagnostic.virtualMemoryRegionInfo.map {
|
||||
String($0.prefix(Self.maxTerminationReasonLength))
|
||||
}
|
||||
storedAny = write(
|
||||
kind: "crash",
|
||||
reason: Self.crashReason(diagnostic),
|
||||
context: context,
|
||||
metaData: diagnostic.metaData,
|
||||
callStackTree: diagnostic.callStackTree
|
||||
) || storedAny
|
||||
}
|
||||
|
||||
for diagnostic in (payload.hangDiagnostics ?? []).prefix(Self.maxDiagnosticsPerKind) {
|
||||
var context = window
|
||||
let seconds = diagnostic.hangDuration.converted(to: .seconds).value
|
||||
context["hangMillis"] = String(Int((seconds * 1_000).rounded()))
|
||||
storedAny = write(
|
||||
kind: "hang",
|
||||
reason: String(format: "hang %.1fs", seconds),
|
||||
context: context,
|
||||
metaData: diagnostic.metaData,
|
||||
callStackTree: diagnostic.callStackTree
|
||||
) || storedAny
|
||||
}
|
||||
|
||||
for diagnostic in (payload.cpuExceptionDiagnostics ?? []).prefix(Self.maxDiagnosticsPerKind) {
|
||||
var context = window
|
||||
context["cpuSeconds"] = String(
|
||||
format: "%.1f",
|
||||
diagnostic.totalCPUTime.converted(to: .seconds).value
|
||||
)
|
||||
context["sampledSeconds"] = String(
|
||||
format: "%.1f",
|
||||
diagnostic.totalSampledTime.converted(to: .seconds).value
|
||||
)
|
||||
storedAny = write(
|
||||
kind: "cpuException",
|
||||
reason: "cpu exception",
|
||||
context: context,
|
||||
metaData: diagnostic.metaData,
|
||||
callStackTree: diagnostic.callStackTree
|
||||
) || storedAny
|
||||
}
|
||||
|
||||
for diagnostic in (payload.diskWriteExceptionDiagnostics ?? []).prefix(Self.maxDiagnosticsPerKind) {
|
||||
var context = window
|
||||
context["writesCausedMB"] = String(
|
||||
format: "%.1f",
|
||||
diagnostic.totalWritesCaused.converted(to: .megabytes).value
|
||||
)
|
||||
storedAny = write(
|
||||
kind: "diskWriteException",
|
||||
reason: "disk write exception",
|
||||
context: context,
|
||||
metaData: diagnostic.metaData,
|
||||
callStackTree: diagnostic.callStackTree
|
||||
) || storedAny
|
||||
}
|
||||
|
||||
return storedAny
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func write(
|
||||
kind: String,
|
||||
reason: String,
|
||||
context: [String: String],
|
||||
metaData: MXMetaData,
|
||||
callStackTree: MXCallStackTree
|
||||
) -> Bool {
|
||||
var merged = context
|
||||
merged["kind"] = kind
|
||||
merged["source"] = "metricKit"
|
||||
// The upload contract constrains these to `[A-Za-z0-9._+-]{1,32}`, and
|
||||
// MetricKit does not respect that: `osVersion` reads like
|
||||
// "iPhone OS 26.0 (23A123)" and `deviceType` like "iPhone17,1". Sanitise
|
||||
// at the point of capture so the stored report and the uploaded one
|
||||
// never disagree.
|
||||
merged["osVersion"] = Self.identifier(metaData.osVersion)
|
||||
merged["deviceType"] = Self.identifier(metaData.deviceType)
|
||||
merged["appBuild"] = Self.identifier(metaData.applicationBuildVersion)
|
||||
merged["platformArchitecture"] = Self.identifier(metaData.platformArchitecture)
|
||||
|
||||
let frames = Self.topBinaryNames(callStackTree)
|
||||
if !frames.isEmpty {
|
||||
merged["topBinaries"] = frames.joined(separator: ",")
|
||||
}
|
||||
if let digest = Self.callStackDigest(callStackTree) {
|
||||
merged["callStackDigest"] = digest
|
||||
}
|
||||
|
||||
// Which process died. MetricKit delivers extension diagnostics to the
|
||||
// containing app, so without this every keyboard crash would be filed
|
||||
// under "host" and the Settings breakdown would lie.
|
||||
let process = Self.originProcess(metaData: metaData, binaries: frames)
|
||||
merged["processBundleID"] = metaData.bundleIdentifier
|
||||
|
||||
let store = lock.withLock { () -> FlowFailureLogStore in
|
||||
if let existing = storesByProcess[process] { return existing }
|
||||
let created = makeStore(process)
|
||||
storesByProcess[process] = created
|
||||
return created
|
||||
}
|
||||
let storedURL = store.persistStartupFailure(
|
||||
reason: "metrickit.\(kind): \(reason)",
|
||||
context: merged
|
||||
)
|
||||
OSGDiag.log(
|
||||
"crashDiagnostics stored=\(storedURL != nil ? 1 : 0) kind=\(kind) "
|
||||
+ "process=\(process.rawValue) reason=\(reason)",
|
||||
category: "diagnostics"
|
||||
)
|
||||
return storedURL != nil
|
||||
}
|
||||
|
||||
// MARK: - Extraction
|
||||
|
||||
/// Reduces a free-form MetricKit string to the `[A-Za-z0-9._+-]{1,32}`
|
||||
/// shape the account service accepts for release identifiers. Returns `nil`
|
||||
/// when nothing usable survives, so the field is simply omitted.
|
||||
static func identifier(_ value: String?) -> String? {
|
||||
guard let value else { return nil }
|
||||
let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
||||
.union(CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz"))
|
||||
.union(CharacterSet(charactersIn: "0123456789._+-"))
|
||||
let mapped = String(
|
||||
String.UnicodeScalarView(
|
||||
value.unicodeScalars.map { allowed.contains($0) ? $0 : "-" }
|
||||
)
|
||||
)
|
||||
// Collapse runs introduced by the substitution so "iPhone OS 26.0 (23A)"
|
||||
// does not become a wall of dashes.
|
||||
var collapsed = ""
|
||||
var lastWasDash = false
|
||||
for character in mapped {
|
||||
let isDash = character == "-"
|
||||
if isDash && lastWasDash { continue }
|
||||
collapsed.append(character)
|
||||
lastWasDash = isDash
|
||||
}
|
||||
let trimmed = collapsed.trimmingCharacters(in: CharacterSet(charactersIn: "-"))
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
return String(trimmed.prefix(32))
|
||||
}
|
||||
|
||||
private static func crashReason(_ diagnostic: MXCrashDiagnostic) -> String {
|
||||
var parts: [String] = []
|
||||
if let type = diagnostic.exceptionType {
|
||||
parts.append("exc=\(type)")
|
||||
}
|
||||
if let signal = diagnostic.signal {
|
||||
parts.append("signal=\(signal)")
|
||||
}
|
||||
if let termination = diagnostic.terminationReason, !termination.isEmpty {
|
||||
parts.append(termination)
|
||||
}
|
||||
return parts.isEmpty ? "crash" : parts.joined(separator: " ")
|
||||
}
|
||||
|
||||
/// The keyboard extension's own binary appears in its call stacks. Prefer
|
||||
/// the bundle identifier when MetricKit supplies one, and fall back to the
|
||||
/// binary names so older payloads are still attributed correctly.
|
||||
private static func originProcess(
|
||||
metaData: MXMetaData,
|
||||
binaries: [String]
|
||||
) -> FlowDiagnosticsProcess {
|
||||
let bundleID = metaData.bundleIdentifier
|
||||
if !bundleID.isEmpty {
|
||||
return bundleID.contains("keyboard") || bundleID.hasSuffix(".OSGKeyboardExt")
|
||||
? .keyboard
|
||||
: .host
|
||||
}
|
||||
return binaries.contains(where: { $0.localizedCaseInsensitiveContains("OSGKeyboardExt") })
|
||||
? .keyboard
|
||||
: .host
|
||||
}
|
||||
|
||||
/// Depth-first walk of the call stack tree collecting distinct binary
|
||||
/// names, attributed threads first. This is what makes a report readable at
|
||||
/// a glance: `OSGKeyboardExt,librime,...` says more than a 300 KB tree.
|
||||
private static func topBinaryNames(_ tree: MXCallStackTree) -> [String] {
|
||||
guard let root = try? JSONSerialization.jsonObject(with: tree.jsonRepresentation()),
|
||||
let dictionary = root as? [String: Any],
|
||||
let stacks = dictionary["callStacks"] as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
let ordered = stacks.sorted { lhs, rhs in
|
||||
(lhs["threadAttributed"] as? Bool ?? false)
|
||||
&& !(rhs["threadAttributed"] as? Bool ?? false)
|
||||
}
|
||||
var names: [String] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
func visit(_ frames: [[String: Any]]) {
|
||||
for frame in frames {
|
||||
guard names.count < maxTopFrames else { return }
|
||||
if let name = frame["binaryName"] as? String, seen.insert(name).inserted {
|
||||
names.append(name)
|
||||
}
|
||||
if let children = frame["subFrames"] as? [[String: Any]] {
|
||||
visit(children)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for stack in ordered {
|
||||
guard names.count < maxTopFrames else { break }
|
||||
visit(stack["callStackRootFrames"] as? [[String: Any]] ?? [])
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
private static func callStackDigest(_ tree: MXCallStackTree) -> String? {
|
||||
guard let text = String(data: tree.jsonRepresentation(), encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
guard text.count > maxCallStackDigestLength else { return text }
|
||||
return String(text.prefix(maxCallStackDigestLength)) + "…[truncated]"
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,15 @@
|
||||
//
|
||||
// Structured logging for the Flow dictation pipeline. Dual-writes to NSLog
|
||||
// (`[OSGDiag/flow]`) and `OSGLog.flow` so Console shows lines even when the
|
||||
// keyboard extension process is selected.
|
||||
// keyboard extension process is selected, and feeds the shared breadcrumb
|
||||
// window that startup-failure reports are cut from.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum FlowDiagnostics {
|
||||
static func log(_ message: String) {
|
||||
FlowFailureLogStore.shared.record(message)
|
||||
FlowFailureDiagnostics.record(message)
|
||||
OSGDiag.log(message, category: "flow")
|
||||
}
|
||||
|
||||
@@ -23,18 +24,6 @@ enum FlowDiagnostics {
|
||||
reason: String,
|
||||
context: [String: String]
|
||||
) -> URL? {
|
||||
let url = FlowFailureLogStore.shared.persistStartupFailure(
|
||||
reason: reason,
|
||||
context: context
|
||||
)
|
||||
if let url {
|
||||
OSGDiag.log(
|
||||
"Flow startup failure report saved file=\(url.lastPathComponent)",
|
||||
category: "flow"
|
||||
)
|
||||
} else {
|
||||
OSGDiag.log("Flow startup failure report could not be saved", category: "flow")
|
||||
}
|
||||
return url
|
||||
FlowFailureDiagnostics.persistStartupFailure(reason: reason, context: context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
// FlowFailureLogStore.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Keeps a short in-memory Flow breadcrumb window and writes it only when a
|
||||
// session start reaches a terminal failure. Reports contain operational state,
|
||||
// never audio, transcripts, clipboard contents, prompts, or credentials.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct FlowFailureLogEvent: Codable, Equatable, Sendable {
|
||||
let timestamp: Date
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct FlowStartupFailureReport: Codable, Equatable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let reportID: UUID
|
||||
let capturedAt: Date
|
||||
let windowStartedAt: Date
|
||||
let reason: String
|
||||
let context: [String: String]
|
||||
let appVersion: String
|
||||
let buildNumber: String
|
||||
let operatingSystem: String
|
||||
let events: [FlowFailureLogEvent]
|
||||
}
|
||||
|
||||
private struct FlowStartupFailureExport: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let generatedAt: Date
|
||||
let reports: [FlowStartupFailureReport]
|
||||
}
|
||||
|
||||
final class FlowFailureLogStore: @unchecked Sendable {
|
||||
static let shared = FlowFailureLogStore()
|
||||
|
||||
static let retentionWindow: TimeInterval = 10
|
||||
|
||||
private let directoryURL: URL
|
||||
private let maxReportCount: Int
|
||||
private let maxReportAge: TimeInterval
|
||||
private let maxTotalBytes: Int
|
||||
private let now: () -> Date
|
||||
private let fileManager: FileManager
|
||||
private let lock = NSLock()
|
||||
private var events: [FlowFailureLogEvent] = []
|
||||
|
||||
init(
|
||||
directoryURL: URL = FlowFailureLogStore.defaultDirectoryURL(),
|
||||
maxReportCount: Int = 20,
|
||||
maxReportAge: TimeInterval = 14 * 24 * 60 * 60,
|
||||
maxTotalBytes: Int = 2 * 1_024 * 1_024,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
fileManager: FileManager = .default
|
||||
) {
|
||||
self.directoryURL = directoryURL
|
||||
self.maxReportCount = maxReportCount
|
||||
self.maxReportAge = maxReportAge
|
||||
self.maxTotalBytes = maxTotalBytes
|
||||
self.now = now
|
||||
self.fileManager = fileManager
|
||||
}
|
||||
|
||||
func record(_ message: String) {
|
||||
let timestamp = now()
|
||||
let event = FlowFailureLogEvent(
|
||||
timestamp: timestamp,
|
||||
message: Self.redact(message)
|
||||
)
|
||||
lock.withLock {
|
||||
events.append(event)
|
||||
pruneEvents(referenceDate: timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func persistStartupFailure(
|
||||
reason: String,
|
||||
context: [String: String]
|
||||
) -> URL? {
|
||||
let capturedAt = now()
|
||||
let capturedEvents = lock.withLock {
|
||||
pruneEvents(referenceDate: capturedAt)
|
||||
return events
|
||||
}
|
||||
let report = FlowStartupFailureReport(
|
||||
schemaVersion: 1,
|
||||
reportID: UUID(),
|
||||
capturedAt: capturedAt,
|
||||
windowStartedAt: capturedEvents.first?.timestamp ?? capturedAt,
|
||||
reason: Self.redact(reason),
|
||||
context: context.mapValues(Self.redact),
|
||||
appVersion: Self.bundleValue("CFBundleShortVersionString"),
|
||||
buildNumber: Self.bundleValue("CFBundleVersion"),
|
||||
operatingSystem: ProcessInfo.processInfo.operatingSystemVersionString,
|
||||
events: capturedEvents
|
||||
)
|
||||
|
||||
do {
|
||||
try prepareDirectory()
|
||||
let fileURL = directoryURL.appendingPathComponent(
|
||||
Self.reportFilename(date: capturedAt, id: report.reportID),
|
||||
isDirectory: false
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
try encoder.encode(report).write(to: fileURL, options: .atomic)
|
||||
try? fileManager.setAttributes(
|
||||
[
|
||||
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication,
|
||||
.modificationDate: capturedAt
|
||||
],
|
||||
ofItemAtPath: fileURL.path
|
||||
)
|
||||
pruneReports(referenceDate: capturedAt)
|
||||
return fileURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func reportURLs() -> [URL] {
|
||||
reportFileURLs().sorted { lhs, rhs in
|
||||
modificationDate(for: lhs) > modificationDate(for: rhs)
|
||||
}
|
||||
}
|
||||
|
||||
func makeExportURL() -> URL? {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
let reports = reportURLs().compactMap { url -> FlowStartupFailureReport? in
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return try? decoder.decode(FlowStartupFailureReport.self, from: data)
|
||||
}
|
||||
guard !reports.isEmpty else { return nil }
|
||||
|
||||
let export = FlowStartupFailureExport(
|
||||
schemaVersion: 1,
|
||||
generatedAt: now(),
|
||||
reports: reports
|
||||
)
|
||||
do {
|
||||
try prepareDirectory()
|
||||
let exportURL = directoryURL.appendingPathComponent(
|
||||
"flow-startup-diagnostics-export.json",
|
||||
isDirectory: false
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||
try encoder.encode(export).write(to: exportURL, options: .atomic)
|
||||
try? fileManager.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: exportURL.path
|
||||
)
|
||||
return exportURL
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func deleteAllReports() {
|
||||
for url in reportFileURLs() {
|
||||
try? fileManager.removeItem(at: url)
|
||||
}
|
||||
try? fileManager.removeItem(
|
||||
at: directoryURL.appendingPathComponent(
|
||||
"flow-startup-diagnostics-export.json",
|
||||
isDirectory: false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func pruneEvents(referenceDate: Date) {
|
||||
let cutoff = referenceDate.addingTimeInterval(-Self.retentionWindow)
|
||||
events.removeAll { $0.timestamp < cutoff }
|
||||
}
|
||||
|
||||
private func prepareDirectory() throws {
|
||||
try fileManager.createDirectory(
|
||||
at: directoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try? fileManager.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: directoryURL.path
|
||||
)
|
||||
}
|
||||
|
||||
private func pruneReports(referenceDate: Date) {
|
||||
let sorted = reportURLs()
|
||||
var retainedBytes = 0
|
||||
|
||||
for (index, url) in sorted.enumerated() {
|
||||
let values = try? url.resourceValues(
|
||||
forKeys: [.contentModificationDateKey, .fileSizeKey]
|
||||
)
|
||||
let modifiedAt = values?.contentModificationDate ?? .distantPast
|
||||
let fileSize = values?.fileSize ?? 0
|
||||
let isExpired = referenceDate.timeIntervalSince(modifiedAt) > maxReportAge
|
||||
let exceedsCount = index >= maxReportCount
|
||||
let exceedsSize = retainedBytes + fileSize > maxTotalBytes
|
||||
|
||||
if isExpired || exceedsCount || exceedsSize {
|
||||
try? fileManager.removeItem(at: url)
|
||||
} else {
|
||||
retainedBytes += fileSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reportFileURLs() -> [URL] {
|
||||
guard let urls = try? fileManager.contentsOfDirectory(
|
||||
at: directoryURL,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey, .fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
return urls.filter {
|
||||
$0.pathExtension == "json" && $0.lastPathComponent.hasPrefix("flow-start-failure-")
|
||||
}
|
||||
}
|
||||
|
||||
private func modificationDate(for url: URL) -> Date {
|
||||
(try? url.resourceValues(forKeys: [.contentModificationDateKey]))
|
||||
.flatMap(\.contentModificationDate) ?? .distantPast
|
||||
}
|
||||
|
||||
private static func defaultDirectoryURL() -> URL {
|
||||
let baseURL = (try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)) ?? FileManager.default.temporaryDirectory
|
||||
return baseURL.appendingPathComponent("FlowDiagnostics", isDirectory: true)
|
||||
}
|
||||
|
||||
private static func bundleValue(_ key: String) -> String {
|
||||
Bundle.main.object(forInfoDictionaryKey: key) as? String ?? "unknown"
|
||||
}
|
||||
|
||||
private static func reportFilename(date: Date, id: UUID) -> String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
let timestamp = formatter.string(from: date)
|
||||
.replacingOccurrences(of: ":", with: "-")
|
||||
return "flow-start-failure-\(timestamp)-\(id.uuidString.lowercased()).json"
|
||||
}
|
||||
|
||||
private static func redact(_ value: String) -> String {
|
||||
var redacted = value.replacingOccurrences(
|
||||
of: #"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[1-5][0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12}"#,
|
||||
with: "<uuid>",
|
||||
options: .regularExpression
|
||||
)
|
||||
redacted = redacted.replacingOccurrences(
|
||||
of: #"/private/var/mobile/Containers/\S+"#,
|
||||
with: "<container-path>",
|
||||
options: .regularExpression
|
||||
)
|
||||
redacted = redacted.replacingOccurrences(
|
||||
of: #"(?i)(api[_-]?key|authorization|bearer|access[_-]?token|refresh[_-]?token)=\S+"#,
|
||||
with: "$1=<redacted>",
|
||||
options: .regularExpression
|
||||
)
|
||||
return redacted
|
||||
}
|
||||
}
|
||||
@@ -672,7 +672,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
|
||||
}
|
||||
|
||||
private func drawLogoCard(in context: CGContext, rect: CGRect, tuckProgress: CGFloat) {
|
||||
let cardPath = UIBezierPath(roundedRect: rect, cornerRadius: 16)
|
||||
let cardPath = UIBezierPath(roundedRect: rect, cornerRadius: Radius.large)
|
||||
|
||||
context.setFillColor(UIColor(OSGColor.pictureInPictureAccent).cgColor)
|
||||
context.addPath(cardPath.cgPath)
|
||||
|
||||
@@ -48,6 +48,56 @@ enum FlowPiPReconciliationDecision: Equatable, Sendable {
|
||||
case startRecovery
|
||||
}
|
||||
|
||||
/// Guarantees that every user-visible session warning (the home-screen
|
||||
/// "cannot start voice session" toast) leaves a report behind.
|
||||
///
|
||||
/// Most warning sites already write their own, better-labelled report on the
|
||||
/// following lines. This gate lets the catch-all skip those and fire only for
|
||||
/// the paths nobody instrumented — which is exactly where a silent toast comes
|
||||
/// from.
|
||||
struct FlowSessionWarningReportGate: Equatable, Sendable {
|
||||
/// An explicit report this recent is assumed to describe the same incident.
|
||||
static let duplicateGrace: TimeInterval = 1.5
|
||||
|
||||
private var lastReportedWarning: String?
|
||||
|
||||
mutating func shouldReport(
|
||||
warning: String,
|
||||
lastExplicitReportAt: Date?,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard lastReportedWarning != warning else { return false }
|
||||
lastReportedWarning = warning
|
||||
guard let lastExplicitReportAt else { return true }
|
||||
return now.timeIntervalSince(lastExplicitReportAt) >= Self.duplicateGrace
|
||||
}
|
||||
|
||||
/// A cleared warning ends the episode: the same text later is a new toast.
|
||||
mutating func warningCleared() {
|
||||
lastReportedWarning = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// One startup-failure report per PiP-loss episode.
|
||||
///
|
||||
/// While the app is backgrounded the heartbeat re-evaluates the lost PiP every
|
||||
/// second; without this gate a single incident would write dozens of identical
|
||||
/// reports and evict everything else from the 40-report budget.
|
||||
struct FlowPiPLossReportGate: Equatable, Sendable {
|
||||
private var reportedForCurrentEpisode = false
|
||||
|
||||
mutating func shouldReport() -> Bool {
|
||||
guard !reportedForCurrentEpisode else { return false }
|
||||
reportedForCurrentEpisode = true
|
||||
return true
|
||||
}
|
||||
|
||||
/// A healthy PiP closes the episode, so the next loss reports again.
|
||||
mutating func pipBecameActive() {
|
||||
reportedForCurrentEpisode = false
|
||||
}
|
||||
}
|
||||
|
||||
enum FlowPiPRecoveryPolicy {
|
||||
static let maxAttempts = 3
|
||||
static let totalBudget: TimeInterval = 5
|
||||
@@ -109,8 +159,34 @@ final class FlowSessionManager: ObservableObject {
|
||||
@Published private(set) var isStarting = false
|
||||
@Published private(set) var sessionExpiresAt: Date?
|
||||
/// Non-nil when continuous capture failed or permissions are missing.
|
||||
@Published private(set) var sessionWarning: String?
|
||||
@Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive
|
||||
@Published private(set) var sessionWarning: String? {
|
||||
didSet {
|
||||
guard let sessionWarning else {
|
||||
sessionWarningReportGate.warningCleared()
|
||||
return
|
||||
}
|
||||
guard sessionWarning != oldValue else { return }
|
||||
// Deferred by one runloop turn on purpose: the paths that raise a
|
||||
// warning keep mutating lifecycle state — and usually write their
|
||||
// own, better-labelled report — on the following lines. Reporting
|
||||
// synchronously here would snapshot half-updated state and
|
||||
// double-count the same incident.
|
||||
Task { @MainActor [weak self] in
|
||||
self?.reportSessionWarningIfUnreported(sessionWarning)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionWarningReportGate = FlowSessionWarningReportGate()
|
||||
@Published private(set) var pipLifecycleState: FlowPiPLifecycleState = .inactive {
|
||||
didSet {
|
||||
// A healthy PiP closes the current loss episode, so the next loss
|
||||
// reports again instead of being deduped against a stale one.
|
||||
if pipLifecycleState == .active { pipLossReportGate.pipBecameActive() }
|
||||
}
|
||||
}
|
||||
|
||||
private var pipLossReportGate = FlowPiPLossReportGate()
|
||||
|
||||
private let capture = FlowContinuousCapture()
|
||||
private let pipController: any FlowPictureInPictureControlling
|
||||
@@ -147,6 +223,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var startupAudioHealthTask: Task<Void, Never>?
|
||||
private var didRunStartupAudioHealthCheck = false
|
||||
private var commandObserver: FlowSessionDarwinObserver?
|
||||
/// Alive for the whole process, not just an active session: the keyboard
|
||||
/// asks for a companion dump exactly when no session exists.
|
||||
private var diagnosticsDumpObserver: FlowSessionDarwinObserver?
|
||||
/// Last recording state the poll loop observed — logs only on transition.
|
||||
private var lastObservedRecordingState: FlowSessionKeys.RecordingState = .idle
|
||||
private var activeSessionId: UUID?
|
||||
@@ -281,6 +360,30 @@ final class FlowSessionManager: ObservableObject {
|
||||
self.handleUnexpectedPiPStop()
|
||||
}
|
||||
FlowTerminationCoordinator.register(self)
|
||||
startDiagnosticsDumpObserver()
|
||||
}
|
||||
|
||||
/// The keyboard extension is the process that witnesses "the session never
|
||||
/// came up", but only the host knows why it never published ready. When the
|
||||
/// keyboard writes a terminal start-failure report it pings us here so the
|
||||
/// host's own breadcrumb window lands next to it in the same export.
|
||||
private func startDiagnosticsDumpObserver() {
|
||||
diagnosticsDumpObserver = FlowSessionDarwinObserver(
|
||||
notificationName: FlowSessionDarwin.diagnosticsDumpNotificationName
|
||||
) { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let request = FlowFailureDiagnostics.consumeHostSnapshotRequest() else {
|
||||
return
|
||||
}
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "keyboardReported:\(request.reason)",
|
||||
context: self.startupFailureContext(
|
||||
trigger: "keyboardDumpRequest",
|
||||
attemptCount: 0,
|
||||
elapsed: Date().timeIntervalSince(request.requestedAt)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -699,6 +802,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
private func handleUnexpectedPiPStop() {
|
||||
guard wantsActiveSession else { return }
|
||||
traceState("pip.unexpectedStop")
|
||||
// Root cause of the "session never comes up while I stay in another
|
||||
// app" report. Persist HERE, not later: without PiP there is nothing
|
||||
// keeping this process alive, so iOS suspends it within seconds and the
|
||||
// in-memory breadcrumb window dies with it. This is the one event that
|
||||
// explains the whole failure, and it must survive the suspension.
|
||||
reportPiPLossIfNeeded(origin: "unexpectedStop")
|
||||
pipLifecycleState = .waitingForForeground
|
||||
sessionWarning = nil
|
||||
if isUtteranceRecording {
|
||||
@@ -721,6 +830,51 @@ final class FlowSessionManager: ObservableObject {
|
||||
isStarting = false
|
||||
pipLifecycleState = .waitingForForeground
|
||||
refreshHostReady()
|
||||
reportPiPLossIfNeeded(origin: "recoveryPausedUntilForeground")
|
||||
}
|
||||
|
||||
/// Catch-all: the home screen is showing a failure toast, so a report must
|
||||
/// exist for it no matter which code path raised the warning.
|
||||
private func reportSessionWarningIfUnreported(_ warning: String) {
|
||||
// Cleared or replaced while we waited — whatever the user sees now will
|
||||
// have triggered its own pass.
|
||||
guard sessionWarning == warning else { return }
|
||||
guard sessionWarningReportGate.shouldReport(
|
||||
warning: warning,
|
||||
lastExplicitReportAt: FlowFailureDiagnostics.lastReportPersistedAt(),
|
||||
now: Date()
|
||||
) else { return }
|
||||
|
||||
var context = startupFailureContext(
|
||||
trigger: "sessionWarningToast",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
context["warningMessage"] = warning
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "sessionWarningShown",
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
/// Persists the moment this process gives up on the PiP keep-alive.
|
||||
///
|
||||
/// Restarting PiP requires the scene to be active, so while the user stays
|
||||
/// in another app this is terminal: the session cannot come back until they
|
||||
/// open the app by hand. The keyboard cannot see the cause — it only sees
|
||||
/// `ready=false` — so the host has to record it while it still can.
|
||||
private func reportPiPLossIfNeeded(origin: String) {
|
||||
guard wantsActiveSession, pipLossReportGate.shouldReport() else { return }
|
||||
var context = startupFailureContext(
|
||||
trigger: "pipLoss:\(origin)",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
context["recoverableWithoutForeground"] = isAppForeground ? "true" : "false"
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: isAppForeground ? "pipLostInForeground" : "pipLostWhileBackgrounded",
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
private func resumeAfterForeground() {
|
||||
@@ -797,17 +951,41 @@ final class FlowSessionManager: ObservableObject {
|
||||
sessionWarning = message
|
||||
debug("capture restart failed: \(message)")
|
||||
refreshHostReady()
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "captureRestartFailed",
|
||||
context: startupFailureContext(
|
||||
trigger: "resumeAfterForeground",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
).merging(
|
||||
["captureError": Self.safeErrorLogMetadata(error)]
|
||||
) { _, new in new }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish whether the keyboard can start a new utterance without jumping to the host app.
|
||||
private func refreshHostReady() {
|
||||
guard isActive else {
|
||||
// Not active yet. Distinguish "no host session at all" from
|
||||
// "received a start request and warming up" so the keyboard can
|
||||
// tell an alive-but-starting host from a dead one and hold its
|
||||
// start budget instead of timing out. When the start is parked
|
||||
// because the app is not foreground (PiP cannot arm from the
|
||||
// background), advertise `.waitingForForeground` so the keyboard
|
||||
// prompts the user to bring the app forward.
|
||||
let startupReason: FlowReadySnapshot.Reason
|
||||
if wantsActiveSession {
|
||||
startupReason = isAppForeground ? .starting : .waitingForForeground
|
||||
} else {
|
||||
startupReason = .noSession
|
||||
}
|
||||
FlowSessionBridge.writeReadySnapshot(
|
||||
FlowReadySnapshot(
|
||||
sessionId: activeSessionId,
|
||||
ready: false,
|
||||
reason: .noSession,
|
||||
reason: startupReason,
|
||||
heartbeatAt: Date().timeIntervalSince1970,
|
||||
engineMode: store.engineMode,
|
||||
localeId: store.localeId,
|
||||
hostGeneration: FlowSessionBridge.currentHostGeneration()
|
||||
@@ -816,6 +994,22 @@ final class FlowSessionManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Invariant: an active host (this process owns a live session) must keep
|
||||
// the App Group `flowSessionActive` flag set. It can be violated from the
|
||||
// outside — a host suspended after a PiP drop stops writing heartbeats, so
|
||||
// the keyboard's `clearIfHostStale()` reads it as dead and clears the flag
|
||||
// while the process is merely asleep. On resume `isActive` is still true,
|
||||
// but `isHostReachable()`/`isHostReady()` stay false forever because they
|
||||
// gate on that flag, so every startflow is rejected as
|
||||
// `coldStartHostReadyNotPublished`. Re-assert it here — the single publish
|
||||
// chokepoint every path (and the 1 s heartbeat loop) funnels through — so
|
||||
// the divergence heals within a second, before the user presses mic.
|
||||
if !FlowSessionBridge.isSessionActive() {
|
||||
FlowSessionBridge.reassertSessionActive()
|
||||
FlowSessionDarwin.postSessionChanged()
|
||||
debug("re-asserted flowSessionActive flag cleared by keyboard stale-host cleanup")
|
||||
}
|
||||
|
||||
let pollingAlive = pollingTask != nil && pollingTask?.isCancelled != true
|
||||
let hasRecentAudio = capture.engineHasRecentAudio(maxAge: 2)
|
||||
let hasPendingDelivery = hasUnacknowledgedTerminalResult()
|
||||
@@ -908,6 +1102,12 @@ final class FlowSessionManager: ObservableObject {
|
||||
isPictureInPictureActive: pipController.isPictureInPictureActive
|
||||
) {
|
||||
case .noSessionIntent:
|
||||
// Silent before: the keyboard just waited out its 8 s budget with
|
||||
// nothing on either timeline explaining why.
|
||||
traceState(
|
||||
"pipRecovery.skipped",
|
||||
extra: "trigger=\(trigger.rawValue) reason=noSessionIntent"
|
||||
)
|
||||
return
|
||||
case .alreadyActive:
|
||||
pipLifecycleState = .active
|
||||
@@ -923,6 +1123,13 @@ final class FlowSessionManager: ObservableObject {
|
||||
pipLifecycleState = .waitingForForeground
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
refreshHostReady()
|
||||
// The most likely cold-start miss: the URL was routed before iOS
|
||||
// reported the scene as active, so the start is deferred and the
|
||||
// keyboard sees nothing but `hostReady=false` until it times out.
|
||||
traceState(
|
||||
"pipRecovery.deferred",
|
||||
extra: "trigger=\(trigger.rawValue) reason=waitForForeground"
|
||||
)
|
||||
return
|
||||
case .startRecovery:
|
||||
break
|
||||
@@ -976,6 +1183,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
guard !Task.isCancelled else { return }
|
||||
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
|
||||
sessionWarning = nil
|
||||
// Advertise "warming" the moment arming begins. PiP arming can take up
|
||||
// to `FlowPiPRecoveryPolicy.totalBudget`; without this the keyboard sees
|
||||
// `.noSession` for that whole window and cannot tell it apart from a
|
||||
// dead host, so it burns its start budget in the dark.
|
||||
refreshHostReady()
|
||||
let recoveryStartedAt = Date()
|
||||
|
||||
guard AppPermissions.flowRequirementsMet else {
|
||||
@@ -1014,6 +1226,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
pipLifecycleState = .waitingForForeground
|
||||
FlowSessionBridge.setHostReady(false)
|
||||
}
|
||||
traceState(
|
||||
"startSessionAsync.aborted",
|
||||
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
|
||||
+ "at=loopTop reason=\(pipRecoveryAbortReason(operation: operation))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1029,7 +1246,14 @@ final class FlowSessionManager: ObservableObject {
|
||||
wantsActiveSession: wantsActiveSession,
|
||||
isAppForeground: isAppForeground,
|
||||
taskIsCancelled: Task.isCancelled
|
||||
) else { return }
|
||||
) else {
|
||||
traceState(
|
||||
"startSessionAsync.aborted",
|
||||
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
|
||||
+ "at=afterRetryDelay reason=\(pipRecoveryAbortReason(operation: operation))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if resetFirstGeneration || attempt > 1 {
|
||||
pipController.resetGeneration()
|
||||
@@ -1063,7 +1287,15 @@ final class FlowSessionManager: ObservableObject {
|
||||
wantsActiveSession: wantsActiveSession,
|
||||
isAppForeground: isAppForeground,
|
||||
taskIsCancelled: Task.isCancelled
|
||||
) else { return }
|
||||
) else {
|
||||
traceState(
|
||||
"startSessionAsync.aborted",
|
||||
extra: "trigger=\(trigger.rawValue) attempt=\(attempt) "
|
||||
+ "at=afterStartAndWait outcome=\(outcome) "
|
||||
+ "reason=\(pipRecoveryAbortReason(operation: operation))"
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
switch outcome {
|
||||
case .started:
|
||||
@@ -1246,6 +1478,17 @@ final class FlowSessionManager: ObservableObject {
|
||||
scheduleColdStartRecovery(duration: nil)
|
||||
}
|
||||
debug("cold-start blocked: host ready contract not published")
|
||||
// This is the toast the user reports as "cannot start voice
|
||||
// session": the keyboard jumped us here and we still could not
|
||||
// publish ready.
|
||||
FlowDiagnostics.persistStartupFailure(
|
||||
reason: "coldStartHostReadyNotPublished",
|
||||
context: startupFailureContext(
|
||||
trigger: "coldStartHandoff",
|
||||
attemptCount: 0,
|
||||
elapsed: 0
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3591,6 +3834,9 @@ final class FlowSessionManager: ObservableObject {
|
||||
self.ensurePiPActive(trigger: .healthCheck, duration: nil)
|
||||
} else {
|
||||
self.pipLifecycleState = .waitingForForeground
|
||||
// Deduped to one report per episode — this branch runs
|
||||
// once a second for as long as the loss lasts.
|
||||
self.reportPiPLossIfNeeded(origin: "heartbeatHealthCheck")
|
||||
}
|
||||
}
|
||||
FlowSessionBridge.writeHeartbeat()
|
||||
@@ -3605,6 +3851,19 @@ final class FlowSessionManager: ObservableObject {
|
||||
FlowDiagnostics.log(message)
|
||||
}
|
||||
|
||||
/// Why a bounded PiP recovery gave up mid-loop. These aborts are not
|
||||
/// failures from the host's point of view — but they *are* the user-visible
|
||||
/// failure on the keyboard side, which sits there until its start budget
|
||||
/// expires. Naming the cause is what makes the two timelines line up.
|
||||
private func pipRecoveryAbortReason(operation: UInt64) -> String {
|
||||
var causes: [String] = []
|
||||
if operation != pipRecoveryOperation { causes.append("supersededOperation") }
|
||||
if !wantsActiveSession { causes.append("sessionIntentCleared") }
|
||||
if !isAppForeground { causes.append("appBackgrounded") }
|
||||
if Task.isCancelled { causes.append("taskCancelled") }
|
||||
return causes.isEmpty ? "unknown" : causes.joined(separator: "+")
|
||||
}
|
||||
|
||||
private func startupFailureContext(
|
||||
trigger: String,
|
||||
attemptCount: Int,
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
// InternalDiagnosticsUploader.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Uploads MetricKit crash / hang diagnostics to the account service — from
|
||||
// INTERNAL BUILDS ONLY (local Debug and TestFlight).
|
||||
//
|
||||
// Why internal-only, deliberately:
|
||||
// • TestFlight already collects ordinary crashes through Apple, but NOT
|
||||
// keyboard-extension memory kills — the system does not classify those as
|
||||
// crashes, so they never reach App Store Connect. MetricKit is the only
|
||||
// way to see them, and there is no API to push MetricKit payloads into
|
||||
// TestFlight, so they have to go to our own endpoint.
|
||||
// • Restricting the upload to internal builds means no App Store user's
|
||||
// device sends anything, which keeps this out of the production privacy
|
||||
// review while still answering "why did the keyboard disappear?" during
|
||||
// the beta.
|
||||
//
|
||||
// Payloads carry crash metadata only — exception type, signal, termination
|
||||
// reason, binary names, a truncated call stack — never audio, transcripts,
|
||||
// clipboard content, prompts, or API keys. `FlowFailureLogStore` redacts the
|
||||
// reason and context values before they are ever written to disk.
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
/// Host-only preference controlling internal diagnostics upload. Stored in
|
||||
/// `UserDefaults.standard` (not the App Group) because only the containing app
|
||||
/// ever uploads — the keyboard extension has no network role here.
|
||||
enum InternalDiagnosticsUploadPreference {
|
||||
static let storageKey = "internalDiagnosticsUploadEnabled"
|
||||
|
||||
/// Defaults to ON for internal builds: a beta tester who never finds the
|
||||
/// toggle should still produce the data the beta exists to gather. App
|
||||
/// Store builds ignore this entirely — see `InternalDiagnosticsUploader`.
|
||||
static var isEnabled: Bool {
|
||||
UserDefaults.standard.object(forKey: storageKey) as? Bool ?? true
|
||||
}
|
||||
|
||||
static func setEnabled(_ enabled: Bool) {
|
||||
UserDefaults.standard.set(enabled, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of one upload sweep. Surfaced in the internal diagnostics screen so
|
||||
/// a tester can tell "nothing to send" from "the backend isn't there yet".
|
||||
enum InternalDiagnosticsUploadStatus: Equatable, Sendable {
|
||||
case disabled
|
||||
/// Analytics are switched off, so there is no installation identifier to
|
||||
/// attach and nothing is sent.
|
||||
case noInstallationIdentifier
|
||||
case idle(pending: Int)
|
||||
case uploaded(count: Int)
|
||||
case endpointUnavailable
|
||||
case retryScheduled(pending: Int, after: Date)
|
||||
case failed(statusCode: Int?)
|
||||
}
|
||||
|
||||
actor InternalDiagnosticsUploader {
|
||||
static let shared = InternalDiagnosticsUploader()
|
||||
|
||||
/// Endpoint follows the existing account-service convention
|
||||
/// (`/v1/analytics/events`, `/v1/analytics/keyboard-usage`). Change this one
|
||||
/// constant if the backend lands on a different route.
|
||||
static let defaultEndpoint = URL(
|
||||
string: "https://account.osglab.com/v1/analytics/diagnostics"
|
||||
)!
|
||||
|
||||
/// Reports sent per sweep. Crash payloads are far larger than analytics
|
||||
/// events, and a tester returning from a crash loop should not spend their
|
||||
/// launch uploading.
|
||||
private static let maximumReportsPerSweep = 5
|
||||
/// Remembered IDs are only needed long enough to outlive the store's own
|
||||
/// 40-report / 14-day retention.
|
||||
private static let maximumRememberedIDs = 200
|
||||
private static let baseRetryDelay: TimeInterval = 60
|
||||
private static let maximumRetryDelay: TimeInterval = 6 * 60 * 60
|
||||
|
||||
private struct UploadState: Codable {
|
||||
var uploadedReportIDs: [UUID] = []
|
||||
var consecutiveFailures: Int = 0
|
||||
var nextAttemptAfter: Date?
|
||||
/// Set when the backend answers 404/410. The route does not exist yet;
|
||||
/// retrying every launch would be pure noise against their logs.
|
||||
var endpointUnavailable: Bool = false
|
||||
}
|
||||
|
||||
private let store: FlowFailureLogStore
|
||||
private let endpoint: URL
|
||||
private let network: any AnalyticsNetworking
|
||||
private let bearerProvider: (any AnalyticsBearerProviding)?
|
||||
private let isInternalBuild: Bool
|
||||
private let isUploadEnabled: @Sendable () -> Bool
|
||||
private let installationIdentifier: @Sendable () async -> UUID?
|
||||
private let now: @Sendable () -> Date
|
||||
private let stateURL: URL?
|
||||
|
||||
private var state = UploadState()
|
||||
private var didLoadState = false
|
||||
private var sweepInProgress = false
|
||||
|
||||
private(set) var lastStatus: InternalDiagnosticsUploadStatus = .idle(pending: 0)
|
||||
|
||||
init(
|
||||
store: FlowFailureLogStore = .shared,
|
||||
endpoint: URL = InternalDiagnosticsUploader.defaultEndpoint,
|
||||
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
|
||||
bearerProvider: (any AnalyticsBearerProviding)? = HostAnalyticsBearerBridge.shared,
|
||||
isInternalBuild: Bool = AppDistributionChannel.allowsInternalTools,
|
||||
isUploadEnabled: @escaping @Sendable () -> Bool = {
|
||||
InternalDiagnosticsUploadPreference.isEnabled
|
||||
},
|
||||
installationIdentifier: @escaping @Sendable () async -> UUID? = {
|
||||
await AnalyticsHostService.shared.installationIdentifierIfEnabled()
|
||||
},
|
||||
now: @escaping @Sendable () -> Date = Date.init,
|
||||
stateURL: URL? = InternalDiagnosticsUploader.defaultStateURL()
|
||||
) {
|
||||
self.store = store
|
||||
self.endpoint = endpoint
|
||||
self.network = network
|
||||
self.bearerProvider = bearerProvider
|
||||
self.isInternalBuild = isInternalBuild
|
||||
self.isUploadEnabled = isUploadEnabled
|
||||
self.installationIdentifier = installationIdentifier
|
||||
self.now = now
|
||||
self.stateURL = stateURL
|
||||
}
|
||||
|
||||
static func defaultStateURL() -> URL? {
|
||||
FlowFailureLogStore.defaultDirectoryURL()
|
||||
.appendingPathComponent("diagnostics-upload-state.json", isDirectory: false)
|
||||
}
|
||||
|
||||
/// Safe to call on every launch and every foreground. Returns the status so
|
||||
/// the caller does not have to poll for it.
|
||||
@discardableResult
|
||||
func uploadPendingReports() async -> InternalDiagnosticsUploadStatus {
|
||||
guard isInternalBuild, isUploadEnabled() else {
|
||||
lastStatus = .disabled
|
||||
return .disabled
|
||||
}
|
||||
guard endpoint.scheme?.lowercased() == "https" else {
|
||||
lastStatus = .endpointUnavailable
|
||||
return .endpointUnavailable
|
||||
}
|
||||
guard !sweepInProgress else { return lastStatus }
|
||||
sweepInProgress = true
|
||||
defer { sweepInProgress = false }
|
||||
|
||||
loadStateIfNeeded()
|
||||
if state.endpointUnavailable {
|
||||
lastStatus = .endpointUnavailable
|
||||
return .endpointUnavailable
|
||||
}
|
||||
|
||||
let pending = pendingReports()
|
||||
guard !pending.isEmpty else {
|
||||
lastStatus = .idle(pending: 0)
|
||||
return lastStatus
|
||||
}
|
||||
if let nextAttempt = state.nextAttemptAfter, now() < nextAttempt {
|
||||
lastStatus = .retryScheduled(pending: pending.count, after: nextAttempt)
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
guard let installationID = await installationIdentifier() else {
|
||||
lastStatus = .noInstallationIdentifier
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
let batch = Array(pending.prefix(Self.maximumReportsPerSweep))
|
||||
var token = try? await bearerProvider?.bearerToken()
|
||||
var outcome = await send(batch.map(\.report), installationID: installationID, token: token)
|
||||
if case .unauthorized = outcome {
|
||||
// One refresh, then treat a second 401 as an ordinary failure so a
|
||||
// broken session cannot spin here.
|
||||
token = try? await bearerProvider?
|
||||
.refreshBearerToken(afterUnauthorizedAccessToken: token)
|
||||
outcome = await send(batch.map(\.report), installationID: installationID, token: token)
|
||||
}
|
||||
|
||||
switch outcome {
|
||||
case .success:
|
||||
batch.forEach { remember($0.report.reportID) }
|
||||
state.consecutiveFailures = 0
|
||||
state.nextAttemptAfter = nil
|
||||
persistState()
|
||||
lastStatus = .uploaded(count: batch.count)
|
||||
OSGDiag.log("diagnosticsUpload sweep uploaded=\(batch.count)", category: "diagnostics")
|
||||
return lastStatus
|
||||
case .endpointMissing:
|
||||
state.endpointUnavailable = true
|
||||
persistState()
|
||||
lastStatus = .endpointUnavailable
|
||||
return lastStatus
|
||||
case .rejected(let statusCode):
|
||||
// The server understood the body and refused it. Resending the same
|
||||
// bytes would fail identically, so drop the batch rather than pin
|
||||
// the queue behind it forever.
|
||||
batch.forEach { remember($0.report.reportID) }
|
||||
persistState()
|
||||
OSGDiag.log(
|
||||
"diagnosticsUpload rejected status=\(statusCode) dropped=\(batch.count)",
|
||||
category: "diagnostics"
|
||||
)
|
||||
lastStatus = .idle(pending: pendingReports().count)
|
||||
return lastStatus
|
||||
case .unauthorized:
|
||||
return finishWithFailure(statusCode: 401)
|
||||
case .transient(let statusCode):
|
||||
return finishWithFailure(statusCode: statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
/// Count of MetricKit reports still waiting, for the internal status row.
|
||||
func pendingReportCount() -> Int {
|
||||
loadStateIfNeeded()
|
||||
return pendingReports().count
|
||||
}
|
||||
|
||||
/// Clears the circuit breaker and backoff so a tester can retry immediately
|
||||
/// once the backend route is deployed.
|
||||
func resetDeliveryState() {
|
||||
loadStateIfNeeded()
|
||||
state.endpointUnavailable = false
|
||||
state.consecutiveFailures = 0
|
||||
state.nextAttemptAfter = nil
|
||||
persistState()
|
||||
lastStatus = .idle(pending: pendingReports().count)
|
||||
}
|
||||
|
||||
// MARK: - Sending
|
||||
|
||||
private enum SendOutcome {
|
||||
case success
|
||||
case unauthorized
|
||||
case endpointMissing
|
||||
case rejected(statusCode: Int)
|
||||
case transient(statusCode: Int?)
|
||||
}
|
||||
|
||||
private func send(
|
||||
_ reports: [FlowStartupFailureReport],
|
||||
installationID: UUID,
|
||||
token: String?
|
||||
) async -> SendOutcome {
|
||||
guard !reports.isEmpty,
|
||||
let body = encodeBody(for: reports, installationID: installationID) else {
|
||||
// Nothing a retry can fix.
|
||||
return .rejected(statusCode: 0)
|
||||
}
|
||||
var headers = [
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
// Lets the backend dedupe if a response is lost after it committed.
|
||||
// The server keys idempotency per report as well, so this is a
|
||||
// belt-and-braces hint for proxies rather than the guarantee.
|
||||
"Idempotency-Key": reports[0].reportID.uuidString.lowercased()
|
||||
]
|
||||
if let token, !token.isEmpty {
|
||||
headers["Authorization"] = "Bearer \(token)"
|
||||
}
|
||||
|
||||
do {
|
||||
let response = try await network.send(
|
||||
AnalyticsHTTPRequest(url: endpoint, headers: headers, body: body)
|
||||
)
|
||||
switch response.statusCode {
|
||||
case 200..<300:
|
||||
return .success
|
||||
case 401, 403:
|
||||
return .unauthorized
|
||||
case 404, 410:
|
||||
return .endpointMissing
|
||||
case 408, 429:
|
||||
return .transient(statusCode: response.statusCode)
|
||||
case 400..<500:
|
||||
return .rejected(statusCode: response.statusCode)
|
||||
default:
|
||||
return .transient(statusCode: response.statusCode)
|
||||
}
|
||||
} catch {
|
||||
return .transient(statusCode: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func encodeBody(
|
||||
for reports: [FlowStartupFailureReport],
|
||||
installationID: UUID
|
||||
) -> Data? {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
|
||||
let payload = DiagnosticsUploadRequest(
|
||||
installationId: installationID.uuidString.lowercased(),
|
||||
channel: "INTERNAL",
|
||||
reports: reports.map(DiagnosticsUploadReport.init(report:))
|
||||
)
|
||||
return try? encoder.encode(payload)
|
||||
}
|
||||
|
||||
/// Mirrors `POST /v1/analytics/diagnostics` on the account service. That
|
||||
/// endpoint decodes strictly (`ignoreUnknownKeys = false`), so adding a
|
||||
/// field here without shipping the server first turns every upload into a
|
||||
/// 400 — keep the two in lockstep.
|
||||
private struct DiagnosticsUploadRequest: Encodable {
|
||||
let installationId: String
|
||||
let channel: String
|
||||
let reports: [DiagnosticsUploadReport]
|
||||
}
|
||||
|
||||
private struct DiagnosticsUploadReport: Encodable {
|
||||
let clientReportId: String
|
||||
let kind: String
|
||||
let process: String
|
||||
let capturedAt: String
|
||||
let exceptionType: String?
|
||||
let exceptionCode: String?
|
||||
let signal: String?
|
||||
let terminationReason: String?
|
||||
let hangMillis: Int64?
|
||||
let topBinaries: String?
|
||||
let callStackDigest: String?
|
||||
let appVersion: String?
|
||||
let buildNumber: String?
|
||||
let osVersion: String?
|
||||
let deviceType: String?
|
||||
|
||||
init(report: FlowStartupFailureReport) {
|
||||
let context = report.context
|
||||
clientReportId = report.reportID.uuidString.lowercased()
|
||||
kind = Self.wireKind(context["kind"])
|
||||
process = report.process == .keyboard ? "KEYBOARD" : "APP"
|
||||
capturedAt = report.capturedAt.formatted(.iso8601)
|
||||
exceptionType = context["exceptionType"]
|
||||
exceptionCode = context["exceptionCode"]
|
||||
signal = context["signal"]
|
||||
terminationReason = context["terminationReason"]
|
||||
hangMillis = context["hangMillis"].flatMap(Int64.init)
|
||||
topBinaries = context["topBinaries"]
|
||||
callStackDigest = context["callStackDigest"]
|
||||
appVersion = report.appVersion
|
||||
buildNumber = report.buildNumber
|
||||
// MetricKit's own OS version is more precise than the host's, and
|
||||
// was already normalised when the report was captured.
|
||||
osVersion = context["osVersion"]
|
||||
deviceType = context["deviceType"]
|
||||
}
|
||||
|
||||
/// The stored reports use the MetricKit-shaped names; the wire uses the
|
||||
/// server's SCREAMING_SNAKE enum.
|
||||
private static func wireKind(_ value: String?) -> String {
|
||||
switch value {
|
||||
case "hang": return "HANG"
|
||||
case "cpuException": return "CPU_EXCEPTION"
|
||||
case "diskWriteException": return "DISK_WRITE_EXCEPTION"
|
||||
default: return "CRASH"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private func pendingReports() -> [(url: URL, report: FlowStartupFailureReport)] {
|
||||
let sent = Set(state.uploadedReportIDs)
|
||||
return store.reportsWithURLs().filter { entry in
|
||||
entry.report.context["source"] == "metricKit"
|
||||
&& !sent.contains(entry.report.reportID)
|
||||
}
|
||||
}
|
||||
|
||||
private func remember(_ reportID: UUID) {
|
||||
state.uploadedReportIDs.append(reportID)
|
||||
if state.uploadedReportIDs.count > Self.maximumRememberedIDs {
|
||||
state.uploadedReportIDs.removeFirst(
|
||||
state.uploadedReportIDs.count - Self.maximumRememberedIDs
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishWithFailure(statusCode: Int?) -> InternalDiagnosticsUploadStatus {
|
||||
state.consecutiveFailures += 1
|
||||
let delay = min(
|
||||
Self.baseRetryDelay * pow(2, Double(state.consecutiveFailures - 1)),
|
||||
Self.maximumRetryDelay
|
||||
)
|
||||
let nextAttempt = now().addingTimeInterval(delay)
|
||||
state.nextAttemptAfter = nextAttempt
|
||||
persistState()
|
||||
OSGDiag.log(
|
||||
"diagnosticsUpload deferred status=\(statusCode.map(String.init) ?? "none") "
|
||||
+ "failures=\(state.consecutiveFailures)",
|
||||
category: "diagnostics"
|
||||
)
|
||||
lastStatus = .retryScheduled(pending: pendingReports().count, after: nextAttempt)
|
||||
return lastStatus
|
||||
}
|
||||
|
||||
private func loadStateIfNeeded() {
|
||||
guard !didLoadState else { return }
|
||||
didLoadState = true
|
||||
guard let stateURL, let data = try? Data(contentsOf: stateURL) else { return }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
state = (try? decoder.decode(UploadState.self, from: data)) ?? UploadState()
|
||||
}
|
||||
|
||||
private func persistState() {
|
||||
guard let stateURL else { return }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
guard let data = try? encoder.encode(state) else { return }
|
||||
try? data.write(to: stateURL, options: .atomic)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: stateURL.path
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// PasteAccessController.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Shared paste-access verification state for the settings and skills screens.
|
||||
// Both surfaces need the same tri-state result of `requestPasteAccess()`
|
||||
// (verified / needs recovery / no text to sample), so the decision lives here
|
||||
// once instead of being re-implemented per view.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PasteAccessController: ObservableObject {
|
||||
/// iOS has granted paste access and the last read succeeded.
|
||||
@Published private(set) var isVerified: Bool
|
||||
/// A read was attempted but denied — the caller should offer a Settings
|
||||
/// recovery path.
|
||||
@Published private(set) var needsRecovery = false
|
||||
/// Drives a "nothing on the clipboard to test with" alert.
|
||||
@Published var showNoTextAlert = false
|
||||
/// A brief post-verification confirmation flash (used by the guide card).
|
||||
@Published private(set) var showSuccess = false
|
||||
|
||||
init() {
|
||||
isVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
}
|
||||
|
||||
/// Re-reads the persisted verification flag, e.g. on appear or foreground.
|
||||
func refresh(clearRecovery: Bool = false) {
|
||||
isVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
if clearRecovery {
|
||||
needsRecovery = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs the explicit paste read and updates state. `flashSuccess` shows
|
||||
/// a short confirmation that auto-dismisses.
|
||||
func verify(flashSuccess: Bool = false, animation: Animation = Motion.soft) {
|
||||
switch AppPermissions.requestPasteAccess() {
|
||||
case .verified:
|
||||
withAnimation(animation) {
|
||||
isVerified = true
|
||||
needsRecovery = false
|
||||
if flashSuccess {
|
||||
showSuccess = true
|
||||
}
|
||||
}
|
||||
guard flashSuccess else { return }
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
withAnimation(animation) {
|
||||
showSuccess = false
|
||||
}
|
||||
}
|
||||
case .noTextAvailable:
|
||||
showNoTextAlert = true
|
||||
case .unavailable:
|
||||
withAnimation(animation) {
|
||||
isVerified = false
|
||||
needsRecovery = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Intentionally exposes no preferences. Its only job is to guarantee the
|
||||
app has an entry in Settings, which is what makes
|
||||
UIApplication.openSettingsURLString resolve to the app's own page
|
||||
instead of dropping the user at the Settings root or app list. -->
|
||||
<key>StringsTable</key>
|
||||
<string>Root</string>
|
||||
<key>PreferenceSpecifiers</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -6,14 +6,13 @@
|
||||
|
||||
import Foundation
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
enum AppL10n {
|
||||
static func string(
|
||||
_ key: String,
|
||||
language: AppUILanguage? = nil
|
||||
) -> String {
|
||||
let lang = language ?? ProviderConfig.shared.uiLanguage
|
||||
let lang = language ?? resolvedUILanguage
|
||||
return AppUILanguage.localizedString(
|
||||
key,
|
||||
tableName: nil,
|
||||
@@ -22,6 +21,20 @@ enum AppL10n {
|
||||
)
|
||||
}
|
||||
|
||||
/// The in-app language override, or `.auto` when it cannot be read.
|
||||
///
|
||||
/// `ProviderConfig` requires the App Group and traps without it — and the
|
||||
/// one screen the app renders in exactly that situation,
|
||||
/// `AppGroupErrorView`, is built entirely from localized strings. Resolving
|
||||
/// the language through `ProviderConfig.shared` there crashes the very
|
||||
/// screen that exists so a provisioning mistake does *not* become a crash
|
||||
/// loop. `.auto` follows the system language, which is the right answer
|
||||
/// when no stored preference is reachable anyway.
|
||||
private static var resolvedUILanguage: AppUILanguage {
|
||||
guard AppGroup.isAvailable else { return .auto }
|
||||
return ProviderConfig.shared.uiLanguage
|
||||
}
|
||||
|
||||
static func format(
|
||||
_ key: String,
|
||||
language: AppUILanguage? = nil,
|
||||
@@ -33,8 +46,4 @@ enum AppL10n {
|
||||
arguments: args
|
||||
)
|
||||
}
|
||||
|
||||
static func text(_ key: String) -> Text {
|
||||
Text(LocalizedStringKey(key))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,7 @@ struct AIAgentSkillsView: View {
|
||||
@ObservedObject private var store = AIAgentSkillLayoutStore.shared
|
||||
|
||||
@State private var editingDraft: SkillEditorDraft?
|
||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@State private var showPasteAccessSuccess = false
|
||||
@StateObject private var pasteAccess = PasteAccessController()
|
||||
@State private var path = NavigationPath()
|
||||
private var skillCardShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
@@ -37,6 +34,7 @@ struct AIAgentSkillsView: View {
|
||||
clipboardAccessGuide
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
PersonalReplyStyleSection()
|
||||
installedSection
|
||||
if !store.skillManagementAvailableSkills.isEmpty {
|
||||
uninstalledSection
|
||||
@@ -45,7 +43,7 @@ struct AIAgentSkillsView: View {
|
||||
.tabBarScrollBottomPadding()
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("skills.title")
|
||||
.navigationTitle(AppL10n.string("skills.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.navigationDestination(for: SkillRoute.self) { route in
|
||||
switch route {
|
||||
@@ -68,7 +66,7 @@ struct AIAgentSkillsView: View {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel(Text("skills.add"))
|
||||
.accessibilityLabel(Text(AppL10n.string("skills.add")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,9 +83,9 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||
isPresented: $showPasteNoTextAlert
|
||||
isPresented: $pasteAccess.showNoTextAlert
|
||||
) {
|
||||
Button("common.done") { showPasteNoTextAlert = false }
|
||||
Button(AppL10n.string("common.done")) { pasteAccess.showNoTextAlert = false }
|
||||
} message: {
|
||||
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||
}
|
||||
@@ -97,7 +95,7 @@ struct AIAgentSkillsView: View {
|
||||
reason: "AIAgentSkillsView.onAppear",
|
||||
force: true
|
||||
)
|
||||
refreshPasteAccessState()
|
||||
pasteAccess.refresh()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
@@ -106,8 +104,7 @@ struct AIAgentSkillsView: View {
|
||||
reason: "AIAgentSkillsView.active",
|
||||
force: true
|
||||
)
|
||||
refreshPasteAccessState()
|
||||
pasteAccessNeedsRecovery = false
|
||||
pasteAccess.refresh(clearRecovery: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,17 +114,17 @@ struct AIAgentSkillsView: View {
|
||||
skillIcon(systemImage: clipboardGuideIcon)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(clipboardGuideTitle)
|
||||
Text(AppL10n.string(clipboardGuideTitle))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(clipboardGuideBody)
|
||||
Text(AppL10n.string(clipboardGuideBody))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
if !showPasteAccessSuccess {
|
||||
if !pasteAccess.showSuccess {
|
||||
Button(action: performClipboardGuideAction) {
|
||||
guideRow(
|
||||
titleKey: clipboardGuideActionTitle,
|
||||
@@ -147,7 +144,7 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
|
||||
private func guideRow(
|
||||
titleKey: LocalizedStringKey,
|
||||
titleKey: String,
|
||||
systemImage: String,
|
||||
trailing: String
|
||||
) -> some View {
|
||||
@@ -157,12 +154,12 @@ struct AIAgentSkillsView: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.frame(width: 22)
|
||||
Text(titleKey)
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: trailing)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
@@ -175,47 +172,47 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
|
||||
private var showsClipboardAccessGuide: Bool {
|
||||
!config.clipboardHistoryEnabled || !pasteAccessVerified || showPasteAccessSuccess
|
||||
!config.clipboardHistoryEnabled || !pasteAccess.isVerified || pasteAccess.showSuccess
|
||||
}
|
||||
|
||||
private var clipboardGuideTitle: LocalizedStringKey {
|
||||
if showPasteAccessSuccess {
|
||||
private var clipboardGuideTitle: String {
|
||||
if pasteAccess.showSuccess {
|
||||
return "skills.clipboard.guide.success.title"
|
||||
}
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.title"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
return "skills.clipboard.guide.recovery.title"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.title"
|
||||
}
|
||||
|
||||
private var clipboardGuideBody: LocalizedStringKey {
|
||||
if showPasteAccessSuccess {
|
||||
private var clipboardGuideBody: String {
|
||||
if pasteAccess.showSuccess {
|
||||
return "skills.clipboard.guide.success.body"
|
||||
}
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.body"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
return "skills.clipboard.guide.recovery.body"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.body"
|
||||
}
|
||||
|
||||
private var clipboardGuideIcon: String {
|
||||
if showPasteAccessSuccess {
|
||||
if pasteAccess.showSuccess {
|
||||
return "checkmark"
|
||||
}
|
||||
return pasteAccessNeedsRecovery ? "exclamationmark" : "clipboard"
|
||||
return pasteAccess.needsRecovery ? "exclamationmark" : "clipboard"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTitle: LocalizedStringKey {
|
||||
private var clipboardGuideActionTitle: String {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
return "skills.clipboard.guide.openSystemSettings"
|
||||
}
|
||||
return "skills.clipboard.guide.verify.action"
|
||||
@@ -225,18 +222,18 @@ struct AIAgentSkillsView: View {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "clock.arrow.circlepath"
|
||||
}
|
||||
return pasteAccessNeedsRecovery ? "gearshape" : "checkmark.shield"
|
||||
return pasteAccess.needsRecovery ? "gearshape" : "checkmark.shield"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionTrailing: String {
|
||||
pasteAccessNeedsRecovery ? "arrow.up.right" : "arrow.right"
|
||||
pasteAccess.needsRecovery ? "arrow.up.right" : "arrow.right"
|
||||
}
|
||||
|
||||
private var clipboardGuideActionIdentifier: String {
|
||||
if !config.clipboardHistoryEnabled {
|
||||
return "skills.clipboard.guide.enableHistory"
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
return "skills.clipboard.guide.openSystemSettings"
|
||||
}
|
||||
return "skills.clipboard.guide.verifyPaste"
|
||||
@@ -249,39 +246,11 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
return
|
||||
}
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
AppPermissions.openSystemSettings()
|
||||
return
|
||||
}
|
||||
verifyPasteAccess()
|
||||
}
|
||||
|
||||
private func verifyPasteAccess() {
|
||||
switch AppPermissions.requestPasteAccess() {
|
||||
case .verified:
|
||||
withAnimation(Motion.soft) {
|
||||
pasteAccessVerified = true
|
||||
pasteAccessNeedsRecovery = false
|
||||
showPasteAccessSuccess = true
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .milliseconds(700))
|
||||
withAnimation(Motion.soft) {
|
||||
showPasteAccessSuccess = false
|
||||
}
|
||||
}
|
||||
case .noTextAvailable:
|
||||
showPasteNoTextAlert = true
|
||||
case .unavailable:
|
||||
withAnimation(Motion.soft) {
|
||||
pasteAccessVerified = false
|
||||
pasteAccessNeedsRecovery = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPasteAccessState() {
|
||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
pasteAccess.verify(flashSuccess: true)
|
||||
}
|
||||
|
||||
private func refreshOfficialSkillCatalog(reason: String, force: Bool) {
|
||||
@@ -305,7 +274,7 @@ struct AIAgentSkillsView: View {
|
||||
)
|
||||
) {
|
||||
if store.skillManagementEnabledSkills.isEmpty {
|
||||
Text("skills.installed.empty")
|
||||
Text(AppL10n.string("skills.installed.empty"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -320,7 +289,7 @@ struct AIAgentSkillsView: View {
|
||||
}
|
||||
|
||||
private var uninstalledSection: some View {
|
||||
CardSection("skills.uninstalled.section") {
|
||||
CardSection(title: AppL10n.string("skills.uninstalled.section")) {
|
||||
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(store.skillManagementAvailableSkills) { skill in
|
||||
skillListItem(skill)
|
||||
@@ -336,7 +305,7 @@ struct AIAgentSkillsView: View {
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
if skill.isUserCreated {
|
||||
Button("common.delete", role: .destructive) {
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
store.deleteUserSkill(id: skill.id)
|
||||
}
|
||||
}
|
||||
@@ -360,7 +329,7 @@ struct AIAgentSkillsView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(width: 18)
|
||||
}
|
||||
@@ -529,12 +498,12 @@ private struct SkillDetailView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("skills.detail.title")
|
||||
.navigationTitle(AppL10n.string("skills.detail.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if skill.isUserCreated {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("skills.edit", action: onEdit)
|
||||
Button(AppL10n.string("skills.edit"), action: onEdit)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,7 +544,7 @@ private struct SkillDetailView: View {
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if skill.isDefault {
|
||||
Text("skills.badge.default")
|
||||
Text(AppL10n.string("skills.badge.default"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -594,7 +563,7 @@ private struct SkillDetailView: View {
|
||||
|
||||
private var promptBlock: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text("skills.editor.prompt")
|
||||
Text(AppL10n.string("skills.editor.prompt"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text(promptText)
|
||||
@@ -611,7 +580,7 @@ private struct SkillDetailView: View {
|
||||
}
|
||||
|
||||
private var thinkingRow: some View {
|
||||
Toggle("skills.editor.thinking", isOn: .constant(skill.thinkingEnabled))
|
||||
Toggle(AppL10n.string("skills.editor.thinking"), isOn: .constant(skill.thinkingEnabled))
|
||||
.disabled(true)
|
||||
.font(TypeStyle.body)
|
||||
}
|
||||
@@ -671,13 +640,13 @@ private struct SkillDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func fullWidthButton(
|
||||
_ titleKey: LocalizedStringKey,
|
||||
_ titleKey: String,
|
||||
prominent: Bool,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
Button(action: action) {
|
||||
Text(titleKey)
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.foregroundStyle(prominent ? palette.textOnAccent : palette.textPrimary)
|
||||
@@ -735,19 +704,19 @@ private struct SkillEditorSheet: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("skills.editor.name") {
|
||||
TextField("skills.editor.namePlaceholder", text: $name)
|
||||
Section(AppL10n.string("skills.editor.name")) {
|
||||
TextField(AppL10n.string("skills.editor.namePlaceholder"), text: $name)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section("skills.editor.summary") {
|
||||
TextField("skills.editor.summaryPlaceholder", text: $summary, axis: .vertical)
|
||||
Section(AppL10n.string("skills.editor.summary")) {
|
||||
TextField(AppL10n.string("skills.editor.summaryPlaceholder"), text: $summary, axis: .vertical)
|
||||
.lineLimit(6...12)
|
||||
.frame(minHeight: 108, alignment: .top)
|
||||
.settingsListRow(alignment: .topLeading)
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section("skills.editor.icon") {
|
||||
Section(AppL10n.string("skills.editor.icon")) {
|
||||
Button {
|
||||
showSymbolPicker = true
|
||||
} label: {
|
||||
@@ -762,7 +731,7 @@ private struct SkillEditorSheet: View {
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("skills.editor.iconChoose")
|
||||
Text(AppL10n.string("skills.editor.iconChoose"))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(systemImage)
|
||||
.font(TypeStyle.caption)
|
||||
@@ -771,7 +740,7 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
@@ -787,7 +756,7 @@ private struct SkillEditorSheet: View {
|
||||
.cardListRow(elevated: false)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("skills.editor.prompt")
|
||||
Text(AppL10n.string("skills.editor.prompt"))
|
||||
Spacer()
|
||||
Text("\(prompt.count)/\(AIUserSkillLimits.maximumPromptCharacters)")
|
||||
.foregroundStyle(
|
||||
@@ -799,7 +768,7 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
Section {
|
||||
VStack(spacing: 0) {
|
||||
TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
|
||||
TextField(AppL10n.string("skills.editor.linkPlaceholder"), text: $shortcutLink)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
.autocorrectionDisabled()
|
||||
@@ -807,12 +776,12 @@ private struct SkillEditorSheet: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
|
||||
TextField(AppL10n.string("skills.editor.shortcutNamePlaceholder"), text: $shortcutName)
|
||||
.settingsListRow()
|
||||
|
||||
if isLookingUp {
|
||||
Divider().background(palette.divider)
|
||||
Text("skills.editor.lookingUp")
|
||||
Text(AppL10n.string("skills.editor.lookingUp"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.settingsListRow()
|
||||
@@ -826,20 +795,20 @@ private struct SkillEditorSheet: View {
|
||||
}
|
||||
.cardListRow(elevated: false)
|
||||
} header: {
|
||||
Text("skills.editor.shortcut")
|
||||
Text(AppL10n.string("skills.editor.shortcut"))
|
||||
} footer: {
|
||||
Text("skills.editor.shortcutHint")
|
||||
Text(AppL10n.string("skills.editor.shortcutHint"))
|
||||
}
|
||||
Section {
|
||||
Toggle("skills.editor.thinking", isOn: $thinkingEnabled)
|
||||
Toggle(AppL10n.string("skills.editor.thinking"), isOn: $thinkingEnabled)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
} footer: {
|
||||
Text("skills.editor.thinkingHint")
|
||||
Text(AppL10n.string("skills.editor.thinkingHint"))
|
||||
}
|
||||
if !draft.isNew {
|
||||
Section {
|
||||
Button("common.delete", role: .destructive) {
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
confirmDelete = true
|
||||
}
|
||||
.settingsListRow()
|
||||
@@ -851,11 +820,11 @@ private struct SkillEditorSheet: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
Button(AppL10n.string("common.cancel")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") {
|
||||
Button(AppL10n.string("common.save")) {
|
||||
do {
|
||||
try save()
|
||||
dismiss()
|
||||
@@ -870,23 +839,23 @@ private struct SkillEditorSheet: View {
|
||||
.sheet(isPresented: $showSymbolPicker) {
|
||||
SkillSymbolPicker(selection: $systemImage)
|
||||
}
|
||||
.alert("skills.delete.title", isPresented: $confirmDelete) {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("common.delete", role: .destructive) {
|
||||
.alert(AppL10n.string("skills.delete.title"), isPresented: $confirmDelete) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
onDelete(draft.id)
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("skills.delete.message")
|
||||
Text(AppL10n.string("skills.delete.message"))
|
||||
}
|
||||
.alert(
|
||||
Text("skills.error.title"),
|
||||
Text(AppL10n.string("skills.error.title")),
|
||||
isPresented: Binding(
|
||||
get: { saveError != nil },
|
||||
set: { if !$0 { saveError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("common.done") { saveError = nil }
|
||||
Button(AppL10n.string("common.done")) { saveError = nil }
|
||||
} message: {
|
||||
Text(saveError ?? "")
|
||||
}
|
||||
@@ -1011,7 +980,7 @@ private struct SkillSymbolPicker: View {
|
||||
symbol == selection
|
||||
? palette.textPrimary.opacity(0.12)
|
||||
: palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||
in: RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -1022,12 +991,12 @@ private struct SkillSymbolPicker: View {
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("skills.editor.icon")
|
||||
.navigationTitle(AppL10n.string("skills.editor.icon"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $query, prompt: Text("skills.editor.iconSearch"))
|
||||
.searchable(text: $query, prompt: Text(AppL10n.string("skills.editor.iconSearch")))
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.done") { dismiss() }
|
||||
Button(AppL10n.string("common.done")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
|
||||
private var controls: some View {
|
||||
VStack(spacing: 10) {
|
||||
Text("技能布局预览")
|
||||
Text(language == .en ? "Skill layout preview" : "技能布局预览")
|
||||
.font(.headline)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
HStack(spacing: 16) {
|
||||
@@ -56,7 +56,7 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
}
|
||||
Text("\(count) 个")
|
||||
Text(language == .en ? "\(count)" : "\(count) 个")
|
||||
.font(.title2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.frame(minWidth: 72)
|
||||
|
||||
@@ -43,6 +43,7 @@ struct AIKeyboardDemoView: View {
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .messages,
|
||||
title: language == .en ? "Messages" : "信息",
|
||||
language: language,
|
||||
text: hostDraft,
|
||||
incoming: incoming,
|
||||
isSent: hostSent
|
||||
@@ -61,7 +62,7 @@ struct AIKeyboardDemoView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
// Keep under what's-new crop (~327 pt visible at 3x).
|
||||
.frame(height: 300)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.bottom, 24)
|
||||
.transition(.opacity)
|
||||
|
||||
@@ -69,10 +69,10 @@ struct ASRSettingsCard: View {
|
||||
Group {
|
||||
Toggle(isOn: volcengineAPIKeyModeBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.volcengine.apiKeyMode.title")
|
||||
Text(AppL10n.string("settings.asr.volcengine.apiKeyMode.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.volcengine.apiKeyMode.subtitle")
|
||||
Text(AppL10n.string("settings.asr.volcengine.apiKeyMode.subtitle"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -25,7 +25,7 @@ struct AccountAppleAuthorizationButton: View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(authorizationButtonForeground)
|
||||
Text("account.signIn.loading")
|
||||
Text(AppL10n.string("account.signIn.loading"))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
}
|
||||
.foregroundStyle(authorizationButtonForeground)
|
||||
@@ -47,13 +47,13 @@ struct AccountAppleAuthorizationButton: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.accessibilityLabel(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.apple")
|
||||
: Text("account.delete.reauthenticate")
|
||||
? Text(AppL10n.string("account.signIn.apple"))
|
||||
: Text(AppL10n.string("account.delete.reauthenticate"))
|
||||
)
|
||||
.accessibilityHint(
|
||||
purpose == .signIn
|
||||
? Text("account.signIn.hint")
|
||||
: Text("account.delete.reauthenticateHint")
|
||||
? Text(AppL10n.string("account.signIn.hint"))
|
||||
: Text(AppL10n.string("account.delete.reauthenticateHint"))
|
||||
)
|
||||
.accessibilityIdentifier(
|
||||
purpose == .signIn
|
||||
|
||||
@@ -101,7 +101,7 @@ struct ManagedCloudConsentUITestHarness: View {
|
||||
.environmentObject(coordinator)
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("settings.aiService.title")
|
||||
.navigationTitle(AppL10n.string("settings.aiService.title"))
|
||||
}
|
||||
}
|
||||
.task {
|
||||
|
||||
@@ -28,7 +28,7 @@ struct AccountCenterView: View {
|
||||
sessionContent
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("account.title")
|
||||
.navigationTitle(AppL10n.string("account.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(palette.background, for: .navigationBar)
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
@@ -49,15 +49,15 @@ struct AccountCenterView: View {
|
||||
coordinator.creditPurchases.dismissSuccessMessage()
|
||||
}
|
||||
.alert(
|
||||
"account.profile.editTitle",
|
||||
AppL10n.string("account.profile.editTitle"),
|
||||
isPresented: $showProfileEditor
|
||||
) {
|
||||
TextField("account.profile.nickname", text: $displayNameDraft)
|
||||
TextField(AppL10n.string("account.profile.nickname"), text: $displayNameDraft)
|
||||
.textInputAutocapitalization(.words)
|
||||
.autocorrectionDisabled()
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
.tint(palette.textPrimary)
|
||||
Button("account.profile.save") {
|
||||
Button(AppL10n.string("account.profile.save")) {
|
||||
if case let .signedIn(session) = coordinator.sessionPhase {
|
||||
saveDisplayName(session: session)
|
||||
}
|
||||
@@ -65,7 +65,7 @@ struct AccountCenterView: View {
|
||||
.disabled(!canSaveCurrentDisplayName)
|
||||
.tint(palette.textPrimary)
|
||||
} message: {
|
||||
Text("account.profile.editMessage")
|
||||
Text(AppL10n.string("account.profile.editMessage"))
|
||||
}
|
||||
.sheet(isPresented: $showDeleteReauthentication) {
|
||||
deleteReauthenticationSheet
|
||||
@@ -98,11 +98,11 @@ struct AccountCenterView: View {
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Text("account.signedOut.title")
|
||||
Text(AppL10n.string("account.signedOut.title"))
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("account.signedOut.body")
|
||||
Text(AppL10n.string("account.signedOut.body"))
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -112,13 +112,13 @@ struct AccountCenterView: View {
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.pendingReferralCode != nil {
|
||||
Label("account.referral.pendingAfterSignIn", systemImage: "link")
|
||||
Label(AppL10n.string("account.referral.pendingAfterSignIn"), systemImage: "link")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Text("account.signedOut.localUnaffected")
|
||||
Text(AppL10n.string("account.signedOut.localUnaffected"))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -183,7 +183,7 @@ struct AccountCenterView: View {
|
||||
}
|
||||
}
|
||||
|
||||
CardSection("account.storekit.section") {
|
||||
CardSection(title: AppL10n.string("account.storekit.section")) {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditPurchaseSection(
|
||||
manager: coordinator.creditPurchases,
|
||||
@@ -206,16 +206,16 @@ struct AccountCenterView: View {
|
||||
} label: {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.font(TypeStyle.headline.weight(.medium))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 24)
|
||||
.accessibilityHidden(true)
|
||||
Text("account.purchaseHistory.title")
|
||||
Text(AppL10n.string("account.purchaseHistory.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
@@ -273,7 +273,7 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.accessibilityLabel("account.profile.editTitle")
|
||||
.accessibilityLabel(AppL10n.string("account.profile.editTitle"))
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.surface, in: shape)
|
||||
@@ -319,11 +319,11 @@ struct AccountCenterView: View {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.confirmationDialog(
|
||||
"account.signOut.confirmTitle",
|
||||
AppL10n.string("account.signOut.confirmTitle"),
|
||||
isPresented: $showSignOutConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("account.signOut.action", role: .destructive) {
|
||||
Button(AppL10n.string("account.signOut.action"), role: .destructive) {
|
||||
Task {
|
||||
await coordinator.signOut()
|
||||
if !coordinator.isSignedIn {
|
||||
@@ -331,9 +331,9 @@ struct AccountCenterView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("account.signOut.confirmMessage")
|
||||
Text(AppL10n.string("account.signOut.confirmMessage"))
|
||||
}
|
||||
|
||||
Divider().background(palette.divider)
|
||||
@@ -351,16 +351,16 @@ struct AccountCenterView: View {
|
||||
.buttonStyle(.plain)
|
||||
.disabled(coordinator.operation != nil)
|
||||
.confirmationDialog(
|
||||
"account.delete.confirmTitle",
|
||||
AppL10n.string("account.delete.confirmTitle"),
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("account.delete.continue", role: .destructive) {
|
||||
Button(AppL10n.string("account.delete.continue"), role: .destructive) {
|
||||
showDeleteReauthentication = true
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("account.delete.confirmMessage")
|
||||
Text(AppL10n.string("account.delete.confirmMessage"))
|
||||
}
|
||||
}
|
||||
.surfaceCard()
|
||||
@@ -374,7 +374,7 @@ struct AccountCenterView: View {
|
||||
.foregroundStyle(palette.danger)
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("account.delete.reauthenticateBody")
|
||||
Text(AppL10n.string("account.delete.reauthenticateBody"))
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
@@ -388,7 +388,7 @@ struct AccountCenterView: View {
|
||||
.disabled(coordinator.operation != nil)
|
||||
|
||||
if coordinator.operation == .deletingAccount {
|
||||
ProgressView("account.delete.loading")
|
||||
ProgressView(AppL10n.string("account.delete.loading"))
|
||||
.tint(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
@@ -397,11 +397,11 @@ struct AccountCenterView: View {
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.delete.reauthenticateTitle")
|
||||
.navigationTitle(AppL10n.string("account.delete.reauthenticateTitle"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") {
|
||||
Button(AppL10n.string("common.cancel")) {
|
||||
showDeleteReauthentication = false
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
@@ -469,7 +469,7 @@ private struct AccountCreditsSection: View {
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
Text(AppL10n.string("account.credits.balance"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -477,7 +477,7 @@ private struct AccountCreditsSection: View {
|
||||
Spacer(minLength: Spacing.md)
|
||||
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
Text("account.credits.used")
|
||||
Text(AppL10n.string("account.credits.used"))
|
||||
Text(
|
||||
snapshot.credits.usedCredits,
|
||||
format: .number.grouping(.automatic)
|
||||
@@ -524,7 +524,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.storekit.loading")
|
||||
Text(AppL10n.string("account.storekit.loading"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -560,7 +560,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
Text(option.credits, format: .number.grouping(.automatic))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
Text(AppL10n.string("account.storekit.credits"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -577,7 +577,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
.controlSize(.small)
|
||||
.tint(OSGColor.fixedLightContent)
|
||||
} else {
|
||||
Text("account.storekit.purchase")
|
||||
Text(AppL10n.string("account.storekit.purchase"))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
.accessibilityIdentifier("account.purchase.\(option.productID)")
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text(option.credits, format: .number)) \(Text("account.storekit.credits")) \(Text(option.displayPrice))"
|
||||
"\(Text(option.credits, format: .number)) \(Text(AppL10n.string("account.storekit.credits"))) \(Text(option.displayPrice))"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -628,13 +628,13 @@ private struct AccountCreditPurchaseSection: View {
|
||||
private func catalogRetryMessage(messageKey: String) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
LocalizedStringKey(messageKey),
|
||||
AppL10n.string(messageKey),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.danger)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button("account.retry") {
|
||||
Button(AppL10n.string("account.retry")) {
|
||||
Task { await manager.refreshCatalog(accountID: accountID) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -647,7 +647,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
Group {
|
||||
switch manager.state {
|
||||
case .pending:
|
||||
Label("account.storekit.pending", systemImage: "clock")
|
||||
Label(AppL10n.string("account.storekit.pending"), systemImage: "clock")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
case .succeeded(let credits):
|
||||
@@ -669,7 +669,7 @@ private struct AccountCreditPurchaseSection: View {
|
||||
case .failed(let messageKey):
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Label(
|
||||
LocalizedStringKey(messageKey),
|
||||
AppL10n.string(messageKey),
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(TypeStyle.footnote)
|
||||
@@ -730,13 +730,13 @@ struct AccountCreditProgress: View {
|
||||
if showsLabels {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
percentageLabel(
|
||||
"account.credits.remaining",
|
||||
AppL10n.string("account.credits.remaining"),
|
||||
fraction: remainingFraction,
|
||||
color: palette.success
|
||||
)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
percentageLabel(
|
||||
"account.credits.used",
|
||||
AppL10n.string("account.credits.used"),
|
||||
fraction: usedFraction,
|
||||
color: palette.textTertiary
|
||||
)
|
||||
@@ -747,7 +747,7 @@ struct AccountCreditProgress: View {
|
||||
}
|
||||
|
||||
private func percentageLabel(
|
||||
_ titleKey: LocalizedStringKey,
|
||||
_ title: String,
|
||||
fraction: Double,
|
||||
color: Color
|
||||
) -> some View {
|
||||
@@ -758,7 +758,7 @@ struct AccountCreditProgress: View {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 7, height: 7)
|
||||
Text(titleKey)
|
||||
Text(title)
|
||||
Text(verbatim: percentageText)
|
||||
.monospacedDigit()
|
||||
}
|
||||
@@ -778,7 +778,7 @@ struct AccountInvitationButton: View {
|
||||
guard invitationURL != nil else { return }
|
||||
showsShareDrawer = true
|
||||
} label: {
|
||||
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
|
||||
Label(AppL10n.string("account.referral.inviteTitle"), systemImage: "person.badge.plus")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
@@ -847,7 +847,7 @@ struct IAPReviewScreenshotHarness: View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("account.credits.section") {
|
||||
CardSection(title: AppL10n.string("account.credits.section")) {
|
||||
VStack(spacing: Spacing.sm) {
|
||||
AccountCreditsSection(snapshot: Self.snapshot)
|
||||
AccountCreditPurchaseSection(
|
||||
@@ -860,7 +860,7 @@ struct IAPReviewScreenshotHarness: View {
|
||||
}
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
.navigationTitle("account.rewards.title")
|
||||
.navigationTitle(AppL10n.string("account.rewards.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
.task {
|
||||
@@ -934,7 +934,7 @@ private final class IAPReviewCreditStore: AccountCreditStore {
|
||||
#endif
|
||||
|
||||
private struct AccountActionRow: View {
|
||||
let titleKey: LocalizedStringKey
|
||||
let titleKey: String
|
||||
let systemImage: String
|
||||
let color: Color
|
||||
let showsProgress: Bool
|
||||
@@ -944,7 +944,7 @@ private struct AccountActionRow: View {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 16, weight: .regular))
|
||||
.frame(width: 20)
|
||||
Text(titleKey)
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if showsProgress {
|
||||
@@ -963,7 +963,7 @@ private struct AccountStateCard: View {
|
||||
|
||||
let systemImage: String
|
||||
let titleKey: String
|
||||
var actionKey: LocalizedStringKey?
|
||||
var actionKey: String?
|
||||
var showsProgress = false
|
||||
var action: () -> Void = {}
|
||||
|
||||
@@ -980,13 +980,13 @@ private struct AccountStateCard: View {
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
Text(LocalizedStringKey(titleKey))
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if let actionKey {
|
||||
Button(actionKey, action: action)
|
||||
Button(AppL10n.string(actionKey), action: action)
|
||||
.font(.headline)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct AccountPurchaseHistoryView: View {
|
||||
Group {
|
||||
switch manager.historyPhase {
|
||||
case .idle, .loading:
|
||||
ProgressView("account.purchaseHistory.loading")
|
||||
ProgressView(AppL10n.string("account.purchaseHistory.loading"))
|
||||
.tint(palette.accent)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .loaded(let records):
|
||||
@@ -40,10 +40,10 @@ struct AccountPurchaseHistoryView: View {
|
||||
.font(.title2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.accessibilityHidden(true)
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
Text(AppL10n.string(messageKey))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Button(AppL10n.string("account.retry")) {
|
||||
Task {
|
||||
await manager.loadPurchaseHistory(
|
||||
accountID: accountID,
|
||||
@@ -58,7 +58,7 @@ struct AccountPurchaseHistoryView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("account.purchaseHistory.title")
|
||||
.navigationTitle(AppL10n.string("account.purchaseHistory.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await manager.loadPurchaseHistory(accountID: accountID)
|
||||
@@ -95,7 +95,7 @@ struct AccountPurchaseHistoryView: View {
|
||||
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
|
||||
}
|
||||
} label: {
|
||||
Text(LocalizedStringKey(errorKey))
|
||||
Text(AppL10n.string(errorKey))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -129,7 +129,7 @@ struct AccountPurchaseHistoryView: View {
|
||||
Text(record.creditsGranted, format: .number.grouping(.automatic))
|
||||
.font(TypeStyle.bodyEmph.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.storekit.credits")
|
||||
Text(AppL10n.string("account.storekit.credits"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -144,7 +144,7 @@ struct AccountPurchaseHistoryView: View {
|
||||
Text(balanceAfterText(record.balanceAfter))
|
||||
.font(TypeStyle.body.monospacedDigit())
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text("account.purchaseHistory.status.credited")
|
||||
Text(AppL10n.string("account.purchaseHistory.status.credited"))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.success)
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@ struct AccountRewardsCard: View {
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
Text(AppL10n.string("account.signedOut.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
Text(AppL10n.string("account.signedOut.body"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -74,10 +74,10 @@ struct AccountRewardsCard: View {
|
||||
creditSummary(snapshot)
|
||||
case .failed:
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text("account.error.load")
|
||||
Text(AppL10n.string("account.error.load"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button("account.retry") {
|
||||
Button(AppL10n.string("account.retry")) {
|
||||
Task { await coordinator.refreshAccountData(force: true) }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
@@ -103,7 +103,7 @@ struct AccountRewardsCard: View {
|
||||
Text(creditRemainingText(snapshot.credits.balance))
|
||||
.font(TypeStyle.title3.monospacedDigit())
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.credits.balance")
|
||||
Text(AppL10n.string("account.credits.balance"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -119,7 +119,7 @@ struct AccountRewardsCard: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(minWidth: 32, minHeight: 32)
|
||||
.accessibilityHidden(true)
|
||||
@@ -137,11 +137,11 @@ struct AccountRewardsCard: View {
|
||||
.padding(Spacing.md)
|
||||
}
|
||||
|
||||
private func loadingContent(_ key: LocalizedStringKey) -> some View {
|
||||
private func loadingContent(_ key: String) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text(key)
|
||||
Text(AppL10n.string(key))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
@@ -199,7 +199,7 @@ private struct AccountReferralLinkView: View {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(palette.accent)
|
||||
Text("account.referral.loadingLink")
|
||||
Text(AppL10n.string("account.referral.loadingLink"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -217,8 +217,8 @@ private struct AccountReferralLinkView: View {
|
||||
private func loadedContent(_ profile: ReferralProfile) -> some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
HStack(spacing: Spacing.md) {
|
||||
Text("account.referral.equalRewardDescription")
|
||||
.font(TypeStyle.caption)
|
||||
Text(AppL10n.string("account.referral.equalRewardDescription"))
|
||||
.font(TypeStyle.body.weight(.bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
@@ -229,7 +229,7 @@ private struct AccountReferralLinkView: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
Text("account.referral.refreshingLink")
|
||||
Text(AppL10n.string("account.referral.refreshingLink"))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
@@ -241,14 +241,14 @@ private struct AccountReferralLinkView: View {
|
||||
|
||||
private func retryRow(
|
||||
messageKey: String,
|
||||
actionKey: LocalizedStringKey
|
||||
actionKey: String
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(LocalizedStringKey(messageKey))
|
||||
Text(AppL10n.string(messageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Button(actionKey) {
|
||||
Button(AppL10n.string(actionKey)) {
|
||||
Task { await viewModel.refresh() }
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
|
||||
@@ -21,17 +21,17 @@ struct AppGroupErrorView: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(palette.danger)
|
||||
Text("appGroup.error.title")
|
||||
Text(AppL10n.string("appGroup.error.title"))
|
||||
.font(TypeStyle.title2)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("appGroup.error.body")
|
||||
Text(AppL10n.string("appGroup.error.body"))
|
||||
.font(TypeStyle.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Label("appGroup.error.step1", systemImage: "1.circle")
|
||||
Label("appGroup.error.step2", systemImage: "2.circle")
|
||||
Label("appGroup.error.step3", systemImage: "3.circle")
|
||||
Label(AppL10n.string("appGroup.error.step1"), systemImage: "1.circle")
|
||||
Label(AppL10n.string("appGroup.error.step2"), systemImage: "2.circle")
|
||||
Label(AppL10n.string("appGroup.error.step3"), systemImage: "3.circle")
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
|
||||
@@ -11,7 +11,7 @@ enum AppTab: Int, CaseIterable {
|
||||
case styles
|
||||
case settings
|
||||
|
||||
var accessibilityKey: LocalizedStringKey {
|
||||
var accessibilityKey: String {
|
||||
switch self {
|
||||
case .keyboard: return "tab.keyboard"
|
||||
case .skills: return "tab.skills"
|
||||
@@ -32,7 +32,7 @@ enum AppTab: Int, CaseIterable {
|
||||
}
|
||||
|
||||
/// Sidebar label for iPad `NavigationSplitView` (SF Symbol + title).
|
||||
var sidebarTitle: LocalizedStringKey { accessibilityKey }
|
||||
var sidebarTitle: String { accessibilityKey }
|
||||
|
||||
/// iPad sidebar. Home stays `house` in both states; other tabs follow
|
||||
/// the same outline / fill pairing as the phone tab bar.
|
||||
|
||||
@@ -15,6 +15,9 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
case skills
|
||||
case semanticBadge
|
||||
case search
|
||||
case autoReplyGuidance
|
||||
case translateResult
|
||||
case yesNoReply
|
||||
}
|
||||
|
||||
@StateObject private var state = KeyboardState()
|
||||
@@ -39,11 +42,25 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
GeometryReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: { _ in }
|
||||
)
|
||||
// Mirror KeyboardSurfaceRoot: the auto-reply guide dims the live
|
||||
// keyboard and floats its pitch on top (keyboard stays visible).
|
||||
ZStack {
|
||||
AIKeyboardView(
|
||||
state: state,
|
||||
typing: typing,
|
||||
onInsert: { _ in }
|
||||
)
|
||||
|
||||
if scenario == .autoReplyGuidance {
|
||||
ClipboardAutoReplyGuideView(
|
||||
onClose: { state.clipboardOverlay = .none },
|
||||
onTry: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
// Constrain to a realistic keyboard band so the scrim dims only
|
||||
// the keyboard region, as it does over a real host app.
|
||||
.frame(height: scenario == .autoReplyGuidance ? 300 : nil)
|
||||
.background(backgroundColor)
|
||||
}
|
||||
.onAppear {
|
||||
@@ -173,6 +190,58 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||
keyboardState?.aiSession.discardReadyAnswer()
|
||||
}
|
||||
case .translateResult:
|
||||
// Staged auto-translate: result shown on the keyboard, inserted only
|
||||
// via the liquid-glass button.
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.activeClipboardSkillID = AIClipboardSkillCatalog.translateID
|
||||
state.autoResultReadOnly = true
|
||||
let utteranceID = UUID()
|
||||
state.aiSession.enter()
|
||||
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
state.aiSession.receiveAnswer(
|
||||
"Let's meet at the cafe tomorrow at 3pm — does that work for you?",
|
||||
utteranceID: utteranceID
|
||||
)
|
||||
state.confirmPendingAIAnswer = { [weak keyboardState] in
|
||||
keyboardState?.aiSession.markAnswerInserted(offersSend: true)
|
||||
}
|
||||
state.discardPendingAIAnswer = { [weak keyboardState] in
|
||||
keyboardState?.aiSession.discardReadyAnswer()
|
||||
}
|
||||
case .yesNoReply:
|
||||
// Both-stance answers for a yes/no question (4 variants > 3).
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.activeClipboardSkillID = AIClipboardSkillCatalog.replyID
|
||||
// Fresh copy present → the leading slot shows the Paste capsule and the
|
||||
// alternates row can rank the skills for this text.
|
||||
state.clipboardSuggestionText = "明天下午三点方便吗?"
|
||||
state.clipboardSuggestionChangeCount = 1
|
||||
ClipboardHistoryStore.shared.ingest(rawText: "明天下午三点方便吗?", changeCount: 1)
|
||||
let utteranceID = UUID()
|
||||
state.aiSession.enter()
|
||||
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
state.aiSession.receiveReplyVariants(
|
||||
[
|
||||
AIReplyVariant(kind: .answerAffirmative, emotion: .neutral,
|
||||
text: "可以的,这个时间我没问题,就这么定。"),
|
||||
AIReplyVariant(kind: .answerNegative, emotion: .neutral,
|
||||
text: "不行,我那天已经排满了,来不了。"),
|
||||
AIReplyVariant(kind: .answerConditional, emotion: .neutral,
|
||||
text: "如果能改到下午三点之后,我就可以。"),
|
||||
AIReplyVariant(kind: .answerDefer, emotion: .neutral,
|
||||
text: "我先确认一下日程,稍后回复你。")
|
||||
],
|
||||
utteranceID: utteranceID
|
||||
)
|
||||
state.selectAIReplyVariant = { [weak keyboardState] id in
|
||||
_ = keyboardState?.aiSession.selectReplyVariant(id: id)
|
||||
}
|
||||
// Deterministic stand-in for the coordinator: switch the active skill
|
||||
// so the alternates row updates when a capsule is tapped.
|
||||
state.submitAIClipboardSkill = { [weak keyboardState] skill, _ in
|
||||
keyboardState?.activeClipboardSkillID = skill.id
|
||||
}
|
||||
case .skillFailure:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.skillTipText = "Skill failed"
|
||||
@@ -203,6 +272,9 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.returnKeyRole = .search
|
||||
state.assistantActionAvailable = true
|
||||
case .autoReplyGuidance:
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.clipboardOverlay = .autoReplyGuide
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,9 +48,13 @@ struct ClipboardHistoryDemoView: View {
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if FeaturePreviewFlags.isFullscreen {
|
||||
// Chinese-only recording — see the `zh-Hans` locale pin on
|
||||
// this view's body; the sample conversation has no English
|
||||
// variant, so the host chrome stays Chinese to match.
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .messages,
|
||||
title: "信息",
|
||||
language: .zh,
|
||||
text: hostDraft,
|
||||
incoming: Self.incomingMessage
|
||||
)
|
||||
|
||||
@@ -10,15 +10,15 @@ import SwiftUI
|
||||
struct PrivacyInfoCard: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: LocalizedStringKey
|
||||
let bodyText: LocalizedStringKey
|
||||
let title: String
|
||||
let bodyText: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(title)
|
||||
Text(AppL10n.string(title))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(bodyText)
|
||||
Text(AppL10n.string(bodyText))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -18,7 +18,7 @@ struct RemoteWebView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
let webView = WKWebView(frame: .zero, configuration: Self.mediaFriendlyConfiguration())
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .clear
|
||||
webView.scrollView.backgroundColor = .clear
|
||||
@@ -37,6 +37,18 @@ struct RemoteWebView: UIViewRepresentable {
|
||||
uiView.load(Self.request(for: url))
|
||||
}
|
||||
|
||||
/// Release-notes pages autoplay muted, inline `<video>` demos. On iPhone the
|
||||
/// default configuration blocks both (`allowsInlineMediaPlayback` is `false`
|
||||
/// and playback needs a user gesture), so the clips render as blank/black
|
||||
/// boxes — invisible on the dark theme. Opt into inline, gesture-free
|
||||
/// playback so the demos actually play.
|
||||
static func mediaFriendlyConfiguration() -> WKWebViewConfiguration {
|
||||
let config = WKWebViewConfiguration()
|
||||
config.allowsInlineMediaPlayback = true
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
return config
|
||||
}
|
||||
|
||||
/// The pages we host send no `Cache-Control`, so the default policy lets
|
||||
/// WebKit serve a heuristically-fresh copy without contacting the server —
|
||||
/// edited release notes could stay stale for hours. Always revalidate.
|
||||
|
||||
@@ -45,9 +45,13 @@ struct EditDemoView: View {
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if FeaturePreviewFlags.isFullscreen {
|
||||
// Chinese-only recording: the sample text below has no
|
||||
// English variant, so the host chrome is pinned to match
|
||||
// (see the `zh-Hans` locale pin on this view's body).
|
||||
FeaturePreviewHostDocument(
|
||||
kind: .notes,
|
||||
title: "备忘录",
|
||||
language: .zh,
|
||||
text: hostText
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -24,7 +24,7 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CardSection("settings.aiService.title") {
|
||||
CardSection(title: AppL10n.string("settings.aiService.title")) {
|
||||
VStack(spacing: 0) {
|
||||
serviceOptionRow(
|
||||
source: .managed,
|
||||
@@ -48,17 +48,17 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
.alert(
|
||||
"settings.aiService.credits.consent.title",
|
||||
AppL10n.string("settings.aiService.credits.consent.title"),
|
||||
isPresented: $showsManagedCloudConsent
|
||||
) {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("settings.aiService.credits.consent.accept") {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("settings.aiService.credits.consent.accept")) {
|
||||
config.hasAcknowledgedCloudSharing = true
|
||||
activateManagedService()
|
||||
}
|
||||
.accessibilityIdentifier("settings.aiService.credits.consent.accept")
|
||||
} message: {
|
||||
Text("settings.aiService.credits.consent.message")
|
||||
Text(AppL10n.string("settings.aiService.credits.consent.message"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@ struct FeaturePreviewHostDocument: View {
|
||||
|
||||
let kind: Kind
|
||||
let title: String
|
||||
/// Recording language for the host chrome this view draws itself
|
||||
/// (everything else is passed in already localized by the caller).
|
||||
var language: WhatsNewDemoScenario.Language = .zh
|
||||
/// Text the keyboard has produced so far. Empty renders the placeholder.
|
||||
let text: String
|
||||
/// Messages only — the message being replied to.
|
||||
@@ -39,7 +42,7 @@ struct FeaturePreviewHostDocument: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
@@ -94,8 +97,8 @@ struct FeaturePreviewHostDocument: View {
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
if isSent {
|
||||
Text("已发送")
|
||||
.font(.system(size: 12))
|
||||
Text(language == .en ? "Sent" : "已发送")
|
||||
.font(TypeStyle.caption.weight(.regular))
|
||||
.foregroundStyle(Color(uiColor: .tertiaryLabel))
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
@@ -139,7 +142,7 @@ struct FeaturePreviewHostDocument: View {
|
||||
|
||||
private var placeholder: String {
|
||||
switch kind {
|
||||
case .notes: return "开始记录…"
|
||||
case .notes: return language == .en ? "Start writing…" : "开始记录…"
|
||||
case .messages: return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,18 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
@ObservedObject private var historyStore = SpeechHistoryStore.shared
|
||||
|
||||
@State private var reportURLs: [URL] = []
|
||||
@State private var reports: [FlowStartupFailureReport] = []
|
||||
@State private var exportURL: URL?
|
||||
@State private var corpusExportURL: URL?
|
||||
@State private var showsClearConfirmation = false
|
||||
@AppStorage(InternalDiagnosticsUploadPreference.storageKey)
|
||||
private var uploadsDiagnostics = true
|
||||
@State private var pendingUploadCount = 0
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.diagnostics.section") {
|
||||
CardSection(title: AppL10n.string("settings.diagnostics.section")) {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(reportCountText)
|
||||
@@ -28,6 +32,11 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
Text(reportStatusText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
if !reports.isEmpty {
|
||||
Text(processBreakdownText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
@@ -60,11 +69,12 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
}
|
||||
|
||||
if AppDistributionChannel.allowsInternalTools {
|
||||
internalUploadSection
|
||||
corpusExportSection
|
||||
}
|
||||
|
||||
CardSection("settings.diagnostics.privacy.section") {
|
||||
Text("settings.diagnostics.privacy.body")
|
||||
CardSection(title: AppL10n.string("settings.diagnostics.privacy.section")) {
|
||||
Text(privacyBodyText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -74,25 +84,114 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.diagnostics.title")
|
||||
.navigationTitle(AppL10n.string("settings.diagnostics.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear(perform: refreshReports)
|
||||
.onAppear {
|
||||
refreshReports()
|
||||
refreshPendingUploadCount()
|
||||
}
|
||||
.onChange(of: uploadsDiagnostics) { _, enabled in
|
||||
guard enabled else { return }
|
||||
// Turning it back on should not wait for the next launch: clear any
|
||||
// backoff left by the disabled period and sweep now.
|
||||
Task {
|
||||
await InternalDiagnosticsUploader.shared.resetDeliveryState()
|
||||
await InternalDiagnosticsUploader.shared.uploadPendingReports()
|
||||
refreshPendingUploadCount()
|
||||
}
|
||||
}
|
||||
.onChange(of: historyStore.entries) {
|
||||
refreshCorpusExport()
|
||||
}
|
||||
.alert("settings.diagnostics.clear.title", isPresented: $showsClearConfirmation) {
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button("settings.diagnostics.clear.confirm", role: .destructive) {
|
||||
.alert(AppL10n.string("settings.diagnostics.clear.title"), isPresented: $showsClearConfirmation) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("settings.diagnostics.clear.confirm"), role: .destructive) {
|
||||
FlowFailureLogStore.shared.deleteAllReports()
|
||||
refreshReports()
|
||||
}
|
||||
} message: {
|
||||
Text("settings.diagnostics.clear.message")
|
||||
Text(AppL10n.string("settings.diagnostics.clear.message"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Beta builds upload; App Store builds never do. Showing the production
|
||||
/// wording ("stays on this device") to a tester would be a false promise.
|
||||
///
|
||||
/// Hoisted out of the view body on purpose: an inline ternary between two
|
||||
/// string literals inside a `Text(...)` argument is enough to push the
|
||||
/// SwiftUI type-checker into reporting a bogus error in an unrelated file
|
||||
/// in the same compile batch.
|
||||
private var privacyBodyText: String {
|
||||
let key = AppDistributionChannel.allowsInternalTools
|
||||
? "settings.diagnostics.privacy.body.internal"
|
||||
: "settings.diagnostics.privacy.body"
|
||||
return AppL10n.string(key, language: config.uiLanguage)
|
||||
}
|
||||
|
||||
/// Internal builds only. App Store builds never upload, so this section —
|
||||
/// and the preference behind it — simply does not exist for them.
|
||||
private var internalUploadSection: some View {
|
||||
CardSection(title: AppL10n.string("settings.diagnostics.upload.section")) {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: $uploadsDiagnostics) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(AppL10n.string(
|
||||
"settings.diagnostics.upload.title",
|
||||
language: config.uiLanguage
|
||||
))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string(
|
||||
"settings.diagnostics.upload.description",
|
||||
language: config.uiLanguage
|
||||
))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
.accessibilityIdentifier("settings.diagnostics.upload.toggle")
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Text(uploadStatusText)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.settingsListRow()
|
||||
.accessibilityIdentifier("settings.diagnostics.upload.status")
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
|
||||
private var uploadStatusText: String {
|
||||
guard uploadsDiagnostics else {
|
||||
return AppL10n.string(
|
||||
"settings.diagnostics.upload.status.off",
|
||||
language: config.uiLanguage
|
||||
)
|
||||
}
|
||||
return String(
|
||||
format: AppL10n.string(
|
||||
"settings.diagnostics.upload.status.pending",
|
||||
language: config.uiLanguage
|
||||
),
|
||||
pendingUploadCount
|
||||
)
|
||||
}
|
||||
|
||||
private func refreshPendingUploadCount() {
|
||||
Task {
|
||||
let pending = await InternalDiagnosticsUploader.shared.pendingReportCount()
|
||||
await MainActor.run { pendingUploadCount = pending }
|
||||
}
|
||||
}
|
||||
|
||||
private var corpusExportSection: some View {
|
||||
CardSection("settings.diagnostics.corpus.section") {
|
||||
CardSection(title: AppL10n.string("settings.diagnostics.corpus.section")) {
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(corpusCountText)
|
||||
@@ -120,7 +219,7 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Text("settings.diagnostics.corpus.privacy")
|
||||
Text(AppL10n.string("settings.diagnostics.corpus.privacy"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -170,6 +269,22 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
)
|
||||
}
|
||||
|
||||
/// Both processes write into the same App Group directory. Splitting the
|
||||
/// count is the fastest way to confirm the keyboard side is capturing —
|
||||
/// a keyboard-observed timeout used to leave no trace at all.
|
||||
private var processBreakdownText: String {
|
||||
let hostCount = reports.filter { $0.process == .host }.count
|
||||
let keyboardCount = reports.filter { $0.process == .keyboard }.count
|
||||
return String(
|
||||
format: AppL10n.string(
|
||||
"settings.diagnostics.processBreakdown",
|
||||
language: config.uiLanguage
|
||||
),
|
||||
hostCount,
|
||||
keyboardCount
|
||||
)
|
||||
}
|
||||
|
||||
private var reportStatusText: String {
|
||||
guard let latest = reportURLs.first,
|
||||
let modifiedAt = try? latest.resourceValues(
|
||||
@@ -191,19 +306,19 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
}
|
||||
|
||||
private func actionRow(
|
||||
title: LocalizedStringKey,
|
||||
title: String,
|
||||
systemImage: String
|
||||
) -> some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(width: 22)
|
||||
Text(title)
|
||||
Text(AppL10n.string(title))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.sm)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
@@ -212,6 +327,7 @@ struct FlowDiagnosticsSettingsView: View {
|
||||
|
||||
private func refreshReports() {
|
||||
reportURLs = FlowFailureLogStore.shared.reportURLs()
|
||||
reports = FlowFailureLogStore.shared.reports()
|
||||
exportURL = FlowFailureLogStore.shared.makeExportURL()
|
||||
refreshCorpusExport()
|
||||
}
|
||||
|
||||
@@ -24,12 +24,12 @@ struct HelpFeedbackView: View {
|
||||
ContentUnavailableView(
|
||||
"settings.support.unavailable.title",
|
||||
systemImage: "wifi.slash",
|
||||
description: Text("settings.support.unavailable.message")
|
||||
description: Text(AppL10n.string("settings.support.unavailable.message"))
|
||||
)
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.link.support")
|
||||
.navigationTitle(AppL10n.string("settings.link.support"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ struct HistoryView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("history.title")
|
||||
.navigationTitle(AppL10n.string("history.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !store.entries.isEmpty {
|
||||
@@ -48,38 +48,38 @@ struct HistoryView: View {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityLabel("history.clear.button")
|
||||
.accessibilityLabel(AppL10n.string("history.clear.button"))
|
||||
.confirmationDialog(
|
||||
"history.clear.title",
|
||||
AppL10n.string("history.clear.title"),
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("history.clear.confirm", role: .destructive) {
|
||||
Button(AppL10n.string("history.clear.confirm"), role: .destructive) {
|
||||
store.clearAll()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("history.clear.message")
|
||||
Text(AppL10n.string("history.clear.message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"history.clearDay.title",
|
||||
AppL10n.string("history.clearDay.title"),
|
||||
isPresented: $showDeleteDayConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("history.clearDay.confirm", role: .destructive) {
|
||||
Button(AppL10n.string("history.clearDay.confirm"), role: .destructive) {
|
||||
if let day = dayPendingDelete {
|
||||
store.deleteEntries(on: day)
|
||||
}
|
||||
dayPendingDelete = nil
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {
|
||||
dayPendingDelete = nil
|
||||
}
|
||||
} message: {
|
||||
Text("history.clearDay.message")
|
||||
Text(AppL10n.string("history.clearDay.message"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ struct HistoryView: View {
|
||||
historyRow(entry)
|
||||
.surfaceCard()
|
||||
.contextMenu {
|
||||
Button("common.delete", role: .destructive) {
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
@@ -127,12 +127,12 @@ struct HistoryView: View {
|
||||
dayPendingDelete = day
|
||||
showDeleteDayConfirmation = true
|
||||
} label: {
|
||||
Text("common.delete")
|
||||
Text(AppL10n.string("common.delete"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.danger)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("history.clearDay.button")
|
||||
.accessibilityLabel(AppL10n.string("history.clearDay.button"))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// CardPageContent owns horizontal padding, so no row-specific
|
||||
@@ -145,7 +145,7 @@ struct HistoryView: View {
|
||||
Spacer()
|
||||
MaterialIcon(name: .menuBook, size: 36)
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
Text("history.empty")
|
||||
Text(AppL10n.string("history.empty"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -155,12 +155,10 @@ struct HomeView: View {
|
||||
return !config.isConfigured
|
||||
}
|
||||
|
||||
private var apiKeySetupMessageKey: LocalizedStringKey {
|
||||
LocalizedStringKey(
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
private var apiKeySetupMessageKey: String {
|
||||
HomeServiceSetupPolicy.apiKeyMessageKey(
|
||||
isLocalEngine: config.isLocalEngine,
|
||||
isASRConfigured: config.isASRConfigured
|
||||
)
|
||||
}
|
||||
|
||||
@@ -330,13 +328,13 @@ struct HomeView: View {
|
||||
|
||||
private var wideHeroHeader: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text("onboarding.welcome.tagline")
|
||||
.font(.system(size: 30, weight: .semibold))
|
||||
Text(AppL10n.string("onboarding.welcome.tagline"))
|
||||
.font(TypeStyle.pageTitle)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.85)
|
||||
|
||||
Text("home.wide.tagline.subtitle")
|
||||
Text(AppL10n.string("home.wide.tagline.subtitle"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
@@ -355,6 +353,8 @@ struct HomeView: View {
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
|
||||
dictionaryCard
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--home-calendar-screenshot") {
|
||||
HomeUsageStatsSection(layout: layout, compact: compact, content: .calendar)
|
||||
} else {
|
||||
standardHomeContentSections(layout: layout, compact: compact)
|
||||
}
|
||||
@@ -404,14 +404,14 @@ struct HomeView: View {
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
Text("settings.personalDictionary.title")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(AppL10n.string("settings.personalDictionary.title"))
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.tracking(0.6)
|
||||
.textCase(.uppercase)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -419,7 +419,7 @@ struct HomeView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if dictionarySuggestions.isEmpty {
|
||||
Text("home.card.dictionary.smart.empty")
|
||||
Text(AppL10n.string("home.card.dictionary.smart.empty"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -441,7 +441,7 @@ struct HomeView: View {
|
||||
pendingDictionarySuggestion = suggestion
|
||||
} label: {
|
||||
Text(suggestion.term)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
@@ -480,7 +480,7 @@ struct HomeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Text("home.card.dictionary.smart.addHint")
|
||||
Text(AppL10n.string("home.card.dictionary.smart.addHint"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
@@ -508,7 +508,7 @@ struct HomeView: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Button("common.cancel") {
|
||||
Button(AppL10n.string("common.cancel")) {
|
||||
pendingDictionarySuggestion = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@@ -519,7 +519,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
addSuggestedTerm(suggestion)
|
||||
} label: {
|
||||
Text("home.card.dictionary.confirm.add")
|
||||
Text(AppL10n.string("home.card.dictionary.confirm.add"))
|
||||
.foregroundStyle(palette.background)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -642,25 +642,25 @@ struct HomeView: View {
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsPermissionSetup || needsAPIKeySetup {
|
||||
Text("home.flow.notReady")
|
||||
Text(AppL10n.string("home.flow.notReady"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
Text(AppL10n.string("home.flow.recording"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
Text(AppL10n.string("home.flow.processing"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
Text(AppL10n.string("home.flow.label"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(":")
|
||||
@@ -698,7 +698,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
flowManager.retryPiPRecovery()
|
||||
} label: {
|
||||
Text("home.flow.retry")
|
||||
Text(AppL10n.string("home.flow.retry"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
@@ -707,7 +707,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
Text(AppL10n.string("home.flow.endShort"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -717,7 +717,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
Text(AppL10n.string("home.flow.endShort"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
@@ -732,7 +732,7 @@ struct HomeView: View {
|
||||
startCapture: true
|
||||
)
|
||||
} label: {
|
||||
Text("home.flow.startShort")
|
||||
Text(AppL10n.string("home.flow.startShort"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
@@ -745,12 +745,12 @@ struct HomeView: View {
|
||||
|
||||
private var apiKeySetupGuidance: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
Text(apiKeySetupMessageKey)
|
||||
Text(AppL10n.string(apiKeySetupMessageKey))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text("home.setup.credits.alternative")
|
||||
Text(AppL10n.string("home.setup.credits.alternative"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -759,7 +759,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
openSettings(apiKeySettingsDeepLink)
|
||||
} label: {
|
||||
Label("home.setup.byok.configure", systemImage: "key")
|
||||
Label(AppL10n.string("home.setup.byok.configure"), systemImage: "key")
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
@@ -773,7 +773,7 @@ struct HomeView: View {
|
||||
Button {
|
||||
openSettings(.aiService)
|
||||
} label: {
|
||||
Label("home.setup.credits.open", systemImage: "sparkles")
|
||||
Label(AppL10n.string("home.setup.credits.open"), systemImage: "sparkles")
|
||||
.foregroundStyle(palette.textOnAccent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.frame(minHeight: 34)
|
||||
|
||||
@@ -29,10 +29,10 @@ struct KeyboardPreviewSheet: View {
|
||||
palette.background.ignoresSafeArea()
|
||||
VStack(spacing: Spacing.md) {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Text("preview.title")
|
||||
Text(AppL10n.string("preview.title"))
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("preview.subtitle")
|
||||
Text(AppL10n.string("preview.subtitle"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -85,7 +85,7 @@ struct KeyboardPreviewSheet: View {
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("preview.clear")
|
||||
.accessibilityLabel(AppL10n.string("preview.clear"))
|
||||
}
|
||||
}
|
||||
TextEditor(text: $typedText)
|
||||
@@ -119,7 +119,7 @@ struct KeyboardPreviewSheet: View {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label {
|
||||
Text("preview.openSettings")
|
||||
Text(AppL10n.string("preview.openSettings"))
|
||||
} icon: {
|
||||
Image(systemName: "gearshape")
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ struct KeyboardPreviewStub: View {
|
||||
Spacer(minLength: 0)
|
||||
Button(action: openSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.font(TypeStyle.footnote.weight(.medium))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(palette.surface, in: Circle())
|
||||
@@ -76,7 +76,7 @@ struct KeyboardPreviewStub: View {
|
||||
Button(action: onModeCycle) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: modeIconName)
|
||||
Text(modeChipLabel)
|
||||
Text(AppL10n.string(modeChipLabel))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -85,14 +85,14 @@ struct KeyboardPreviewStub: View {
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.modeChip.cycle"))
|
||||
.accessibilityLabel(Text(AppL10n.string("preview.modeChip.cycle")))
|
||||
}
|
||||
|
||||
private var localeChip: some View {
|
||||
Button(action: onLocaleCycle) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "globe")
|
||||
Text(localeChipLabel)
|
||||
Text(AppL10n.string(localeChipLabel))
|
||||
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
@@ -101,7 +101,7 @@ struct KeyboardPreviewStub: View {
|
||||
.background(palette.surfaceElevated, in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("preview.localeChip.cycle"))
|
||||
.accessibilityLabel(Text(AppL10n.string("preview.localeChip.cycle")))
|
||||
}
|
||||
|
||||
/// Display label for the mode chip. The mode ids are
|
||||
@@ -109,12 +109,12 @@ struct KeyboardPreviewStub: View {
|
||||
/// from `Localizable.strings` and fall back to the raw id if a
|
||||
/// translation is missing (shouldn't happen, but cheaper than
|
||||
/// crashing in the preview).
|
||||
private var modeChipLabel: LocalizedStringKey {
|
||||
private var modeChipLabel: String {
|
||||
switch modeId {
|
||||
case "off": return "settings.mode.off"
|
||||
case "transcribe": return "settings.mode.transcribe"
|
||||
case "polish": return "settings.mode.polish"
|
||||
default: return LocalizedStringKey(modeId)
|
||||
default: return modeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ struct KeyboardPreviewStub: View {
|
||||
/// locale list the Settings view also uses — see `staticLocales` in
|
||||
/// `SettingsView.swift`. We keep the list inline here so the
|
||||
/// preview doesn't need a settings dependency.
|
||||
private var localeChipLabel: LocalizedStringKey {
|
||||
private var localeChipLabel: String {
|
||||
switch localeId {
|
||||
case "auto": return "locale.auto"
|
||||
case "zh-Hans": return "locale.zh-Hans"
|
||||
@@ -130,7 +130,7 @@ struct KeyboardPreviewStub: View {
|
||||
case "en-US": return "locale.en-US"
|
||||
case "ja-JP": return "locale.ja-JP"
|
||||
case "ko-KR": return "locale.ko-KR"
|
||||
default: return LocalizedStringKey(localeId)
|
||||
default: return localeId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ struct KeyboardPreviewStub: View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("keyboard.placeholder.idle")
|
||||
Text(AppL10n.string("keyboard.placeholder.idle"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
case .recording:
|
||||
@@ -172,7 +172,7 @@ struct KeyboardPreviewStub: View {
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||
Text("keyboard.placeholder.processing")
|
||||
Text(AppL10n.string("keyboard.placeholder.processing"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -286,7 +286,7 @@ struct KeyboardPreviewStub: View {
|
||||
HStack(spacing: Spacing.xxs) {
|
||||
iconButton("delete.left")
|
||||
Button(action: {}) {
|
||||
Text("common.space")
|
||||
Text(AppL10n.string("common.space"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 42)
|
||||
|
||||
@@ -34,7 +34,7 @@ struct LocalModelsGroup: View {
|
||||
|
||||
private var speechRow: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text("settings.localModels.speechRole")
|
||||
Text(AppL10n.string("settings.localModels.speechRole"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
@@ -48,7 +48,7 @@ struct LocalModelsGroup: View {
|
||||
/// Local ASR still works without a key; polish requires the Settings LLM key.
|
||||
private var polishRow: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Text("settings.localModels.polishRole")
|
||||
Text(AppL10n.string("settings.localModels.polishRole"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
@@ -57,7 +57,7 @@ struct LocalModelsGroup: View {
|
||||
Text(LLMProvider.provider(id: config.providerId).name)
|
||||
)
|
||||
} else {
|
||||
Text("settings.localModels.polishNeedsKey")
|
||||
Text(AppL10n.string("settings.localModels.polishNeedsKey"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
@@ -71,10 +71,10 @@ struct LocalModelsGroup: View {
|
||||
private var customLanguageModelDiagnosticRow: some View {
|
||||
Toggle(isOn: $config.localASRCustomLanguageModelEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.localModels.customLM.title")
|
||||
Text(AppL10n.string("settings.localModels.customLM.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.localModels.customLM.subtitle")
|
||||
Text(AppL10n.string("settings.localModels.customLM.subtitle"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
@@ -88,14 +88,14 @@ struct LocalModelsGroup: View {
|
||||
|
||||
/// Accent badge naming the engine that backs each local-mode row
|
||||
/// (e.g. "Apple iOS Speech" for ASR).
|
||||
private func engineBadge(_ labelKey: LocalizedStringKey) -> some View {
|
||||
engineBadge(Text(labelKey))
|
||||
private func engineBadge(_ labelKey: String) -> some View {
|
||||
engineBadge(Text(AppL10n.string(labelKey)))
|
||||
}
|
||||
|
||||
private func engineBadge(_ label: Text) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
label
|
||||
.font(TypeStyle.caption)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import OSGKeyboardHostSupport
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
enum AnalyticsFirstOpenAttribution {
|
||||
static let ordinaryLaunch: AnalyticsAcquisitionChannel = .appStoreOrganic
|
||||
@@ -17,6 +18,20 @@ enum AnalyticsFirstOpenAttribution {
|
||||
}
|
||||
}
|
||||
|
||||
/// One native Reminders export handed off from the keyboard. Identifiable so a
|
||||
/// repeated skill run with the same titles still re-triggers the writer.
|
||||
struct ReminderDraft: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let titles: [String]
|
||||
}
|
||||
|
||||
/// One native Calendar export handed off from the keyboard. `lines` are the
|
||||
/// canonical `start|end|title|location` entries `AIEventExporter` decodes.
|
||||
struct EventDraft: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let lines: [String]
|
||||
}
|
||||
|
||||
struct MainAppRoot: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
@@ -33,6 +48,10 @@ struct MainAppRoot: View {
|
||||
@State private var firstOpenAcquisitionChannel =
|
||||
AnalyticsFirstOpenAttribution.ordinaryLaunch
|
||||
@State private var newContactDraft: NewContactDraft?
|
||||
@State private var reminderDraft: ReminderDraft?
|
||||
@State private var reminderOutcome: AIReminderExporter.Outcome?
|
||||
@State private var eventDraft: EventDraft?
|
||||
@State private var eventOutcome: AIEventExporter.Outcome?
|
||||
|
||||
init(accountDependencies: AccountDependencies? = nil) {
|
||||
let resolvedDependencies = accountDependencies ?? LiveAccountDependencyFactory.make()
|
||||
@@ -75,15 +94,15 @@ struct MainAppRoot: View {
|
||||
.environmentObject(accountSession)
|
||||
.environmentObject(analytics)
|
||||
.alert(
|
||||
"account.error.title",
|
||||
AppL10n.string("account.error.title"),
|
||||
isPresented: accountOperationErrorBinding
|
||||
) {
|
||||
Button("common.done") {
|
||||
Button(AppL10n.string("common.done")) {
|
||||
accountSession.dismissOperationError()
|
||||
}
|
||||
} message: {
|
||||
if let key = accountSession.operationErrorKey {
|
||||
Text(LocalizedStringKey(key))
|
||||
Text(AppL10n.string(key))
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -109,6 +128,58 @@ struct MainAppRoot: View {
|
||||
newContactDraft = nil
|
||||
}
|
||||
}
|
||||
.task(id: reminderDraft?.id) {
|
||||
guard let draft = reminderDraft else { return }
|
||||
let outcome = await AIReminderExporter.add(titles: draft.titles)
|
||||
reminderDraft = nil
|
||||
reminderOutcome = outcome
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("skills.reminders.resultTitle"),
|
||||
isPresented: reminderOutcomeBinding
|
||||
) {
|
||||
if reminderOutcome == .accessDenied {
|
||||
Button(AppL10n.string("skills.reminders.openSettings")) {
|
||||
reminderOutcome = nil
|
||||
openSystemSettings()
|
||||
}
|
||||
Button(AppL10n.string("common.done"), role: .cancel) {
|
||||
reminderOutcome = nil
|
||||
}
|
||||
} else {
|
||||
Button(AppL10n.string("common.done")) {
|
||||
reminderOutcome = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(AppL10n.string(reminderOutcomeMessage))
|
||||
}
|
||||
.task(id: eventDraft?.id) {
|
||||
guard let draft = eventDraft else { return }
|
||||
let outcome = await AIEventExporter.add(lines: draft.lines)
|
||||
eventDraft = nil
|
||||
eventOutcome = outcome
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("skills.events.resultTitle"),
|
||||
isPresented: eventOutcomeBinding
|
||||
) {
|
||||
if eventOutcome == .accessDenied {
|
||||
Button(AppL10n.string("skills.events.openSettings")) {
|
||||
eventOutcome = nil
|
||||
openSystemSettings()
|
||||
}
|
||||
Button(AppL10n.string("common.done"), role: .cancel) {
|
||||
eventOutcome = nil
|
||||
}
|
||||
} else {
|
||||
Button(AppL10n.string("common.done")) {
|
||||
eventOutcome = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text(AppL10n.string(eventOutcomeMessage))
|
||||
}
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
// Register the URL handler BEFORE the foreground auto-start.
|
||||
@@ -170,6 +241,12 @@ struct MainAppRoot: View {
|
||||
await accountSession.validateAppleCredentialState()
|
||||
}
|
||||
config.reloadFromPersistedStorage()
|
||||
// Sweep after the session is restored so the upload carries a
|
||||
// bearer token. No-op outside internal builds. This is the retry
|
||||
// path: MetricKit itself triggers an upload the moment a payload
|
||||
// arrives, but a send that failed then only gets another chance
|
||||
// here, on a later launch.
|
||||
await InternalDiagnosticsUploader.shared.uploadPendingReports()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
config.reloadFromPersistedStorage()
|
||||
@@ -255,6 +332,51 @@ struct MainAppRoot: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var reminderOutcomeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { reminderOutcome != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { reminderOutcome = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var reminderOutcomeMessage: String {
|
||||
switch reminderOutcome {
|
||||
case .created:
|
||||
return "skills.reminders.resultSuccess"
|
||||
case .accessDenied:
|
||||
return "skills.reminders.resultDenied"
|
||||
case .failed, .none:
|
||||
return "skills.reminders.resultFailed"
|
||||
}
|
||||
}
|
||||
|
||||
private var eventOutcomeBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { eventOutcome != nil },
|
||||
set: { isPresented in
|
||||
if !isPresented { eventOutcome = nil }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var eventOutcomeMessage: String {
|
||||
switch eventOutcome {
|
||||
case .created:
|
||||
return "skills.events.resultSuccess"
|
||||
case .accessDenied:
|
||||
return "skills.events.resultDenied"
|
||||
case .failed, .none:
|
||||
return "skills.events.resultFailed"
|
||||
}
|
||||
}
|
||||
|
||||
private func openSystemSettings() {
|
||||
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
private func refreshOfficialSkillCatalog(reason: String) {
|
||||
Task {
|
||||
let outcome = await OfficialSkillCatalogRefreshService.shared.refreshIfNeeded(
|
||||
@@ -312,15 +434,30 @@ struct MainAppRoot: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if config.hasCompletedOnboarding {
|
||||
if config.hasCompletedOnboarding, !Self.forcesOnboarding {
|
||||
MainTabView()
|
||||
.id("main")
|
||||
.id("main-\(config.uiLanguage.rawValue)")
|
||||
} else {
|
||||
OnboardingExperienceView(config: config)
|
||||
.id("onboarding")
|
||||
.id("onboarding-\(config.uiLanguage.rawValue)")
|
||||
}
|
||||
}
|
||||
|
||||
/// DEBUG-only: replay the first-run experience without wiping data — used to
|
||||
/// preview onboarding on a device that already finished setup. Pass
|
||||
/// `--force-onboarding` to start from the top, or `--oobe-preview=reply`
|
||||
/// (voice / translate / reply / ask-ai / login / complete) to jump straight
|
||||
/// to a specific step. Both are set in the Xcode scheme's launch arguments.
|
||||
private static var forcesOnboarding: Bool {
|
||||
#if DEBUG
|
||||
let arguments = ProcessInfo.processInfo.arguments
|
||||
return arguments.contains("--force-onboarding")
|
||||
|| arguments.contains(where: { $0.hasPrefix("--oobe-preview=") })
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Delayed host CLM warmup. Never parallel with ASR.
|
||||
/// ASR warms on first mic press (`FlowSessionManager.beginUtterance`).
|
||||
///
|
||||
@@ -377,7 +514,14 @@ struct MainAppRoot: View {
|
||||
if url.path.contains("shortcut-result") {
|
||||
AIAgentShortcutRunner.logShortcutCallback(url)
|
||||
} else if url.path.contains("run") {
|
||||
AIAgentShortcutRunner.runPendingIfNeeded()
|
||||
AIAgentShortcutRunner.runPendingIfNeeded(
|
||||
onReminders: { titles in
|
||||
reminderDraft = ReminderDraft(titles: titles)
|
||||
},
|
||||
onEvents: { lines in
|
||||
eventDraft = EventDraft(lines: lines)
|
||||
}
|
||||
)
|
||||
}
|
||||
case "deployrime":
|
||||
// The keyboard sends the user here precisely because typing
|
||||
|
||||
@@ -76,7 +76,7 @@ struct MainSplitView: View {
|
||||
}
|
||||
|
||||
private var devicesFooter: some View {
|
||||
Label("home.wide.devices", systemImage: "ipad.and.iphone")
|
||||
Label(AppL10n.string("home.wide.devices"), systemImage: "ipad.and.iphone")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -109,7 +109,10 @@ private struct WideSidebarRow: View {
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Label(tab.sidebarTitle, systemImage: tab.sidebarSystemImage(selected: isSelected))
|
||||
Label(
|
||||
AppL10n.string(tab.sidebarTitle),
|
||||
systemImage: tab.sidebarSystemImage(selected: isSelected)
|
||||
)
|
||||
.labelStyle(SidebarIconColumnLabelStyle())
|
||||
// Slightly larger label + taller vertical padding so each
|
||||
// sidebar row hits the Apple HIG 44pt touch target on iPad
|
||||
@@ -226,28 +229,28 @@ private struct WideStatusFooter: View {
|
||||
.frame(width: 6, height: 6)
|
||||
|
||||
if needsAPIKeySetup {
|
||||
Text("home.flow.notReady")
|
||||
Text(AppL10n.string("home.flow.notReady"))
|
||||
.foregroundStyle(palette.warning)
|
||||
} else if flowManager.isUtteranceRecording {
|
||||
Text("home.flow.recording")
|
||||
Text(AppL10n.string("home.flow.recording"))
|
||||
} else if flowManager.isUtteranceProcessing {
|
||||
Text("home.flow.processing")
|
||||
Text(AppL10n.string("home.flow.processing"))
|
||||
} else if flowManager.isActive,
|
||||
FlowSessionBridge.isHostReady(),
|
||||
let expires = flowManager.sessionExpiresAt {
|
||||
Text("home.flow.label")
|
||||
Text(AppL10n.string("home.flow.label"))
|
||||
Text(":")
|
||||
Text(expires, style: .timer)
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Text(flowStatusLabel)
|
||||
Text(AppL10n.string(flowStatusLabel))
|
||||
}
|
||||
|
||||
if flowManager.isActive {
|
||||
Button {
|
||||
flowManager.endSession()
|
||||
} label: {
|
||||
Text("home.flow.endShort")
|
||||
Text(AppL10n.string("home.flow.endShort"))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -258,7 +261,7 @@ private struct WideStatusFooter: View {
|
||||
startCapture: true
|
||||
)
|
||||
} label: {
|
||||
Text("home.flow.startShort")
|
||||
Text(AppL10n.string("home.flow.startShort"))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -290,7 +293,7 @@ private struct WideStatusFooter: View {
|
||||
return palette.textTertiary
|
||||
}
|
||||
|
||||
private var flowStatusLabel: LocalizedStringKey {
|
||||
private var flowStatusLabel: String {
|
||||
if needsAPIKeySetup {
|
||||
return "home.flow.notReady"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ struct MainTabView: View {
|
||||
|
||||
private func tabLabel(for item: AppTab) -> some View {
|
||||
Label {
|
||||
Text(item.accessibilityKey)
|
||||
Text(AppL10n.string(item.accessibilityKey))
|
||||
} icon: {
|
||||
Image(systemName: item.dockSystemImage(selected: tab == item))
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ struct NotesHostDemoView: View {
|
||||
.ignoresSafeArea()
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text(title)
|
||||
.font(.system(size: 13, weight: .regular))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Color(uiColor: .secondaryLabel))
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -291,7 +291,7 @@ struct OnboardingView: View {
|
||||
Button {
|
||||
withAnimation(Motion.soft) { config.onboardingPage = previous }
|
||||
} label: {
|
||||
Text("common.back")
|
||||
Text(AppL10n.string("common.back"))
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
@@ -346,22 +346,22 @@ private enum OnboardingLayoutMetrics {
|
||||
private struct OnboardingTitleBlock: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let title: LocalizedStringKey
|
||||
var subtitle: LocalizedStringKey?
|
||||
var secondarySubtitle: LocalizedStringKey?
|
||||
let title: String
|
||||
var subtitle: String?
|
||||
var secondarySubtitle: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Text(title)
|
||||
Text(AppL10n.string(title))
|
||||
.font(TypeStyle.title3)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
Text(AppL10n.string(subtitle))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
if let secondarySubtitle {
|
||||
Text(secondarySubtitle)
|
||||
Text(AppL10n.string(secondarySubtitle))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -447,7 +447,7 @@ private struct MicPermissionPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusLabel: LocalizedStringKey {
|
||||
private var statusLabel: String {
|
||||
switch status {
|
||||
case .undetermined: return "onboarding.permission.status.undetermined"
|
||||
case .granted: return "onboarding.permission.status.granted"
|
||||
@@ -463,7 +463,7 @@ private struct MicPermissionPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryButtonTitle: LocalizedStringKey {
|
||||
private var primaryButtonTitle: String {
|
||||
switch status {
|
||||
case .granted: return "onboarding.permission.status.granted"
|
||||
case .denied: return "onboarding.permission.openSettings"
|
||||
@@ -514,7 +514,7 @@ private struct SpeechPermissionPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusLabel: LocalizedStringKey {
|
||||
private var statusLabel: String {
|
||||
switch status {
|
||||
case .undetermined: return "onboarding.permission.status.undetermined"
|
||||
case .granted: return "onboarding.permission.status.granted"
|
||||
@@ -530,7 +530,7 @@ private struct SpeechPermissionPage: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var primaryButtonTitle: LocalizedStringKey {
|
||||
private var primaryButtonTitle: String {
|
||||
switch status {
|
||||
case .granted: return "onboarding.permission.status.granted"
|
||||
case .denied, .restricted: return "onboarding.permission.openSettings"
|
||||
@@ -555,17 +555,17 @@ private struct PermissionPageLayout: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
let icon: String
|
||||
let title: LocalizedStringKey
|
||||
let detail: LocalizedStringKey
|
||||
var preface: LocalizedStringKey?
|
||||
let status: LocalizedStringKey
|
||||
let title: String
|
||||
let detail: String
|
||||
var preface: String?
|
||||
let status: String
|
||||
let statusColor: Color
|
||||
let primaryTitle: LocalizedStringKey
|
||||
let primaryTitle: String
|
||||
let primaryDisabled: Bool
|
||||
let onPrimary: () -> Void
|
||||
var secondaryTitle: LocalizedStringKey?
|
||||
var secondaryTitle: String?
|
||||
var onSecondary: (() -> Void)?
|
||||
var deniedHint: LocalizedStringKey?
|
||||
var deniedHint: String?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
@@ -573,7 +573,7 @@ private struct PermissionPageLayout: View {
|
||||
Spacer(minLength: Spacing.lg)
|
||||
|
||||
if let preface {
|
||||
Text(preface)
|
||||
Text(AppL10n.string(preface))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -588,14 +588,14 @@ private struct PermissionPageLayout: View {
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Circle().fill(statusColor).frame(width: 8, height: 8)
|
||||
Text(status)
|
||||
Text(AppL10n.string(status))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.padding(.top, OnboardingLayoutMetrics.heroTextGap)
|
||||
|
||||
if let deniedHint {
|
||||
Text(deniedHint)
|
||||
Text(AppL10n.string(deniedHint))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.warning)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -605,7 +605,7 @@ private struct PermissionPageLayout: View {
|
||||
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Button(action: onPrimary) {
|
||||
Text(primaryTitle)
|
||||
Text(AppL10n.string(primaryTitle))
|
||||
.primaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -613,7 +613,7 @@ private struct PermissionPageLayout: View {
|
||||
|
||||
if let secondaryTitle, let onSecondary {
|
||||
Button(action: onSecondary) {
|
||||
Text(secondaryTitle)
|
||||
Text(AppL10n.string(secondaryTitle))
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -666,7 +666,7 @@ private struct EnableKeyboardPage: View {
|
||||
Button {
|
||||
AppPermissions.openSystemSettings()
|
||||
} label: {
|
||||
Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
|
||||
Label(AppL10n.string("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
|
||||
.primaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -694,7 +694,7 @@ private struct EnableKeyboardPage: View {
|
||||
case .deploying:
|
||||
HStack(spacing: Spacing.xs) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text(LocalizedStringKey("onboarding.enable.resources.preparing"))
|
||||
Text(AppL10n.string("onboarding.enable.resources.preparing"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -702,7 +702,7 @@ private struct EnableKeyboardPage: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(palette.accent)
|
||||
Text(LocalizedStringKey("onboarding.enable.resources.ready"))
|
||||
Text(AppL10n.string("onboarding.enable.resources.ready"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -710,10 +710,10 @@ private struct EnableKeyboardPage: View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(palette.warning)
|
||||
Text(LocalizedStringKey("onboarding.enable.resources.failed"))
|
||||
Text(AppL10n.string("onboarding.enable.resources.failed"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Button(LocalizedStringKey("onboarding.enable.resources.retry")) {
|
||||
Button(AppL10n.string("onboarding.enable.resources.retry")) {
|
||||
deployment.deployNow(force: true, reason: "onboarding.retry")
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
@@ -739,14 +739,14 @@ private struct EnableKeyboardPage: View {
|
||||
HStack(alignment: .top, spacing: Spacing.sm) {
|
||||
stepLabel(num)
|
||||
HStack(alignment: .firstTextBaseline, spacing: 3) {
|
||||
Text("onboarding.enable.step3.prefix")
|
||||
Text(AppL10n.string("onboarding.enable.step3.prefix"))
|
||||
Image(systemName: "globe")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.alignmentGuide(.firstTextBaseline) { dimensions in
|
||||
dimensions[.bottom] - dimensions.height * 0.12
|
||||
}
|
||||
Text("onboarding.enable.step3.suffix")
|
||||
Text(AppL10n.string("onboarding.enable.step3.suffix"))
|
||||
}
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
@@ -800,7 +800,7 @@ private struct APISetupPage: View {
|
||||
.surfaceCard()
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
} else {
|
||||
Text("settings.aiService.credits.subtitle")
|
||||
Text(AppL10n.string("settings.aiService.credits.subtitle"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -832,13 +832,13 @@ private struct PolishSetupPage: View {
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
|
||||
if config.credentialSource == .managed {
|
||||
Text("settings.aiService.credits.subtitle")
|
||||
Text(AppL10n.string("settings.aiService.credits.subtitle"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
} else if config.isLocalEngine {
|
||||
Text("onboarding.polish.localHint")
|
||||
Text(AppL10n.string("onboarding.polish.localHint"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
@@ -16,7 +16,7 @@ struct OpenSourceLicensesView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent(spacing: SettingsListMetrics.sectionLabelSpacing) {
|
||||
Text("settings.licenses.footer")
|
||||
Text(AppL10n.string("settings.licenses.footer"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -40,7 +40,7 @@ struct OpenSourceLicensesView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.licenses.title")
|
||||
.navigationTitle(AppL10n.string("settings.licenses.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ struct OpenSourceLicensesView: View {
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
|
||||
@@ -23,7 +23,7 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
TextField("settings.personalDictionary.add.field", text: $term)
|
||||
TextField(AppL10n.string("settings.personalDictionary.add.field"), text: $term)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.tint(palette.textPrimary)
|
||||
@@ -36,7 +36,7 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
|
||||
Text("settings.personalDictionary.add.footer")
|
||||
Text(AppL10n.string("settings.personalDictionary.add.footer"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -54,11 +54,11 @@ struct PersonalDictionaryEntrySheet: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
Button(AppL10n.string("common.cancel")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") { save() }
|
||||
Button(AppL10n.string("common.save")) { save() }
|
||||
.disabled(trimmedTerm.isEmpty)
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.personalDictionary.title")
|
||||
.navigationTitle(AppL10n.string("settings.personalDictionary.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !dictionary.entries.isEmpty {
|
||||
@@ -57,7 +57,7 @@ struct PersonalDictionaryView: View {
|
||||
}
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.personalDictionary.clearAll.message")
|
||||
Text(AppL10n.string("settings.personalDictionary.clearAll.message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,7 +110,7 @@ struct PersonalDictionaryView: View {
|
||||
entryRow(entry)
|
||||
.surfaceCard()
|
||||
.contextMenu {
|
||||
Button("common.delete", role: .destructive) {
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
delete(entry)
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ struct PersonalDictionaryView: View {
|
||||
Text("·")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
Text("settings.personalDictionary.aliases.generating")
|
||||
Text(AppL10n.string("settings.personalDictionary.aliases.generating"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else if !entry.aliases.isEmpty {
|
||||
@@ -181,10 +181,10 @@ struct PersonalDictionaryView: View {
|
||||
Image(systemName: "square.stack.3d.down.right.fill")
|
||||
.font(.system(size: 36, weight: .regular))
|
||||
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
||||
Text("settings.personalDictionary.empty.title")
|
||||
Text(AppL10n.string("settings.personalDictionary.empty.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Text("settings.personalDictionary.empty.body")
|
||||
Text(AppL10n.string("settings.personalDictionary.empty.body"))
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -193,7 +193,7 @@ struct PersonalDictionaryView: View {
|
||||
editingEntry = nil
|
||||
showEntrySheet = true
|
||||
} label: {
|
||||
Text("settings.personalDictionary.add.title")
|
||||
Text(AppL10n.string("settings.personalDictionary.add.title"))
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(palette.accent)
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
// PersonalReplyStyleSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// The distilled personal style, shown at the top of the Skills tab.
|
||||
//
|
||||
// It deliberately does not live on the Styles page: a learned pack injected
|
||||
// into voice polish outranks the core filler / repetition cleanup and puts the
|
||||
// speaker's disfluencies back into the transcript. Styles shape dictation; this
|
||||
// shapes AI replies, and the two selections are independent.
|
||||
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct PersonalReplyStyleErrorAlert {
|
||||
let title: String
|
||||
let message: String
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct PersonalReplyStyleSection: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
@ObservedObject private var config = ProviderConfig.shared
|
||||
@ObservedObject private var history = SpeechHistoryStore.shared
|
||||
|
||||
@State private var catalog = AppGroupStore().polishStyleCatalog
|
||||
@State private var enabledStyleID = AppGroupStore().personalReplyStyleId
|
||||
@State private var isGenerating = false
|
||||
@State private var generationTask: Task<Void, Never>?
|
||||
@State private var generationID: UUID?
|
||||
@State private var editingPack: PolishStylePack?
|
||||
@State private var errorAlert: PersonalReplyStyleErrorAlert?
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let generator: LearnedStyleGenerator
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
generator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
|
||||
try await PolishStyleLearningService(store: AppGroupStore())
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language,
|
||||
minimumEffectiveCharacterCount:
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
? PolishStyleLearningCorpusBuilder
|
||||
.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder
|
||||
.requiredEffectiveCharacterCount
|
||||
)
|
||||
}
|
||||
) {
|
||||
_editingPack = State(initialValue: initialEditingPack)
|
||||
self.generator = generator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
CardSection(title: AppL10n.string("personalReplyStyle.section")) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
if let pack = learnedPack {
|
||||
learnedCard(pack, corpus: corpus)
|
||||
} else {
|
||||
generationCard
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingPack) { pack in
|
||||
PolishStyleEditorSheet(
|
||||
pack: pack,
|
||||
isNew: !catalog.entries.contains(where: { $0.id == pack.id })
|
||||
) { saved in
|
||||
save(saved)
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
Text(errorAlert?.title ?? ""),
|
||||
isPresented: Binding(
|
||||
get: { errorAlert != nil },
|
||||
set: { if !$0 { errorAlert = nil } }
|
||||
)
|
||||
) {
|
||||
Button(AppL10n.string("common.done")) { errorAlert = nil }
|
||||
} message: {
|
||||
Text(errorAlert?.message ?? "")
|
||||
}
|
||||
.task { reload() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .polishStylesDidSyncFromCloud)) { _ in
|
||||
reload()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
reload()
|
||||
}
|
||||
.onDisappear { cancelGeneration() }
|
||||
}
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private var corpus: PolishStyleLearningCorpus {
|
||||
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
|
||||
}
|
||||
|
||||
/// Debug + TestFlight builds unlock generation at a lower character gate so
|
||||
/// internal testers can exercise the pipeline.
|
||||
private var minimumCharacterCount: Int {
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
? PolishStyleLearningCorpusBuilder.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
}
|
||||
|
||||
private func isEligible(_ corpus: PolishStyleLearningCorpus) -> Bool {
|
||||
corpus.effectiveCharacterCount >= minimumCharacterCount
|
||||
}
|
||||
|
||||
private var learnedPack: PolishStylePack? {
|
||||
PolishStylePackCatalog.latestPersonalReplyStyle(userCatalog: catalog)
|
||||
}
|
||||
|
||||
// MARK: - Cards
|
||||
|
||||
private func learnedCard(
|
||||
_ pack: PolishStylePack,
|
||||
corpus: PolishStyleLearningCorpus
|
||||
) -> some View {
|
||||
let canRegenerate = isEligible(corpus) && !isGenerating
|
||||
let actionShape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "person.wave.2.fill")
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(pack.displayName(language: config.uiLanguage))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string("personalReplyStyle.generated.description"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
editingPack = pack
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isGenerating)
|
||||
.accessibilityLabel(Text(AppL10n.string("polishStyles.edit")))
|
||||
}
|
||||
|
||||
if let metadata = pack.learningMetadata {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "clock")
|
||||
Text(AppL10n.string("personalReplyStyle.generatedAt"))
|
||||
Text(metadata.generatedAt, format: .dateTime.year().month().day())
|
||||
}
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"personalReplyStyle.evidenceSummary",
|
||||
Int64(metadata.asrEffectiveCharacterCount),
|
||||
Int64(metadata.replyExampleCount),
|
||||
Int64(metadata.replyFinalEditCount)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"personalReplyStyle.confidence",
|
||||
Self.confidencePercentage(metadata.confidence)
|
||||
)
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
|
||||
Button {
|
||||
generate(from: corpus, replacing: pack)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isGenerating {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text(
|
||||
isGenerating
|
||||
? "personalReplyStyle.regenerating"
|
||||
: "personalReplyStyle.regenerate"
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(canRegenerate ? palette.textPrimary : palette.textTertiary)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.surfaceElevated, in: actionShape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canRegenerate)
|
||||
.accessibilityIdentifier("personalReplyStyle.regenerate")
|
||||
.accessibilityValue(Text(pack.id))
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.surfaceCard()
|
||||
.accessibilityIdentifier("personalReplyStyle.card")
|
||||
}
|
||||
|
||||
private var generationCard: some View {
|
||||
let corpus = corpus
|
||||
let required = minimumCharacterCount
|
||||
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
let isActionAvailable = isEligible(corpus) && !reachedLimit
|
||||
let canGenerate = isActionAvailable && !isGenerating
|
||||
let completed = min(corpus.effectiveCharacterCount, required)
|
||||
let fraction = required > 0 ? Double(completed) / Double(required) : 0
|
||||
let progressDescription = AppL10n.format(
|
||||
"personalReplyStyle.progress",
|
||||
Int64(corpus.effectiveCharacterCount),
|
||||
Int64(required)
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(AppL10n.string("personalReplyStyle.empty.title"))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string("personalReplyStyle.empty.body"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressTrack(
|
||||
segments: [ProgressTrackSegment(fraction: fraction, color: palette.accent)]
|
||||
)
|
||||
.accessibilityLabel(Text(AppL10n.string("personalReplyStyle.empty.title")))
|
||||
.accessibilityValue(Text(progressDescription))
|
||||
|
||||
HStack {
|
||||
Text(progressDescription)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(
|
||||
isEligible(corpus)
|
||||
? AppL10n.string("personalReplyStyle.ready")
|
||||
: AppL10n.format(
|
||||
"personalReplyStyle.remaining",
|
||||
Int64(max(0, required - corpus.effectiveCharacterCount))
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(isEligible(corpus) ? palette.accent : palette.textTertiary)
|
||||
}
|
||||
|
||||
Button {
|
||||
generate(from: corpus)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isGenerating {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
|
||||
} else {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
Text(
|
||||
isGenerating
|
||||
? AppL10n.string("personalReplyStyle.generating")
|
||||
: AppL10n.string("personalReplyStyle.action")
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
.background(
|
||||
isActionAvailable ? palette.accent : palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canGenerate)
|
||||
.accessibilityIdentifier("personalReplyStyle.generate")
|
||||
|
||||
Text(
|
||||
reachedLimit
|
||||
? AppL10n.string("personalReplyStyle.limit")
|
||||
: AppL10n.string("personalReplyStyle.privacy")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func generate(
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
replacing existingPack: PolishStylePack? = nil
|
||||
) {
|
||||
guard isEligible(corpus), !isGenerating, generationTask == nil else { return }
|
||||
let id = UUID()
|
||||
generationID = id
|
||||
isGenerating = true
|
||||
generationTask = Task { @MainActor in
|
||||
defer { finishGeneration(id: id) }
|
||||
do {
|
||||
let generated = try await generator(
|
||||
corpus,
|
||||
ClipboardReplyFeedbackStore.shared.learningExamples(),
|
||||
config.uiLanguage
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard generationID == id, editingPack == nil else { return }
|
||||
// Always let the user inspect and edit the learned prompt before
|
||||
// it is saved, synced, or enabled.
|
||||
if let existingPack {
|
||||
editingPack = PolishStylePack(
|
||||
id: existingPack.id,
|
||||
name: generated.name,
|
||||
prompt: generated.prompt,
|
||||
allowsAddedEmoji: generated.allowsAddedEmoji,
|
||||
learningMetadata: generated.learningMetadata,
|
||||
kind: .user,
|
||||
createdAt: existingPack.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
} else {
|
||||
editingPack = generated
|
||||
}
|
||||
} catch {
|
||||
guard generationID == id,
|
||||
!Task.isCancelled,
|
||||
!Self.isCancellation(error) else { return }
|
||||
errorAlert = PersonalReplyStyleErrorAlert(
|
||||
title: AppL10n.string("personalReplyStyle.error.title"),
|
||||
message: localizedLearningError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelGeneration() {
|
||||
generationTask?.cancel()
|
||||
generationTask = nil
|
||||
generationID = nil
|
||||
isGenerating = false
|
||||
}
|
||||
|
||||
private func finishGeneration(id: UUID) {
|
||||
guard generationID == id else { return }
|
||||
generationTask = nil
|
||||
generationID = nil
|
||||
isGenerating = false
|
||||
}
|
||||
|
||||
/// Saving a personal style enables it for replies. It must never touch
|
||||
/// `activePolishStyleId` — the store would reject it anyway, but writing
|
||||
/// there at all is what used to conflate the two pipelines.
|
||||
private func save(_ pack: PolishStylePack) -> Bool {
|
||||
var updatedCatalog = catalog
|
||||
do {
|
||||
try updatedCatalog.upsert(pack)
|
||||
catalog = updatedCatalog
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setPersonalReplyStyleId(pack.id)
|
||||
enabledStyleID = store.personalReplyStyleId
|
||||
pushSync()
|
||||
return true
|
||||
} catch {
|
||||
errorAlert = PersonalReplyStyleErrorAlert(
|
||||
title: AppL10n.string("polishStyles.error.title"),
|
||||
message: localized(error)
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Distilling a style *is* the decision to use it, so there is no on/off
|
||||
/// switch. A pack that exists without being the enabled ID — an opt-out
|
||||
/// left by an older build, or a cloud merge that dropped the pointer — is
|
||||
/// adopted here, so the card can never show a style that does nothing.
|
||||
private func reload() {
|
||||
catalog = store.polishStyleCatalog
|
||||
enabledStyleID = store.personalReplyStyleId
|
||||
guard enabledStyleID.isEmpty, let pack = learnedPack else { return }
|
||||
store.setPersonalReplyStyleId(pack.id)
|
||||
enabledStyleID = store.personalReplyStyleId
|
||||
pushSync()
|
||||
}
|
||||
|
||||
private func pushSync() {
|
||||
Task {
|
||||
try? await PolishStyleCloudSync.shared.pushLocalIfEnabled(catalog)
|
||||
try? await AppCloudSync.shared.settingsSyncService.pushLocalIfEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
private func localizedLearningError(_ error: Error) -> String {
|
||||
switch error as? PolishStyleLearningError {
|
||||
case .insufficientCorpus:
|
||||
return AppL10n.string("personalReplyStyle.error.insufficient")
|
||||
case .invalidResponse:
|
||||
return AppL10n.string("polishStyles.learn.error.invalidResponse")
|
||||
case .promptTooLong:
|
||||
return AppL10n.string("polishStyles.learn.error.promptTooLong")
|
||||
case .requestTooLarge:
|
||||
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
|
||||
case nil:
|
||||
return PolishStyleLearningFailureMessage.localized(
|
||||
for: error,
|
||||
language: config.uiLanguage
|
||||
) ?? AppL10n.string("polishStyles.learn.error.request")
|
||||
}
|
||||
}
|
||||
|
||||
private func localized(_ error: Error) -> String {
|
||||
switch error as? PolishStyleValidationError {
|
||||
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
|
||||
case .emptyPrompt: return AppL10n.string("polishStyles.error.emptyPrompt")
|
||||
case .tooManyUserPacks: return AppL10n.string("polishStyles.error.limit")
|
||||
case .promptTooLong: return AppL10n.string("polishStyles.error.promptTooLong")
|
||||
case .builtinIsImmutable: return AppL10n.string("polishStyles.error.builtin")
|
||||
case nil: return AppL10n.string("polishStyles.error.generic")
|
||||
}
|
||||
}
|
||||
|
||||
private static func confidencePercentage(_ confidence: Double) -> Int64 {
|
||||
Int64((min(max(confidence, 0), 1) * 100).rounded())
|
||||
}
|
||||
|
||||
private static func isCancellation(_ error: Error) -> Bool {
|
||||
error is CancellationError || (error as? LLMError) == .cancelled
|
||||
}
|
||||
}
|
||||
@@ -32,12 +32,8 @@ struct PolishStylesView: View {
|
||||
@State private var editingPack: PolishStylePack?
|
||||
@State private var viewingPack: PolishStylePack?
|
||||
@State private var errorAlert: PolishStyleErrorAlert?
|
||||
@State private var isGeneratingLearnedStyle = false
|
||||
@State private var learnedStyleGenerationTask: Task<Void, Never>?
|
||||
@State private var learnedStyleGenerationID: UUID?
|
||||
|
||||
private let store = AppGroupStore()
|
||||
private let learnedStyleGenerator: LearnedStyleGenerator
|
||||
private let pullsCloudStylesOnAppear: Bool
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: CardLayoutMetrics.compactItemSpacing),
|
||||
@@ -46,39 +42,16 @@ struct PolishStylesView: View {
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
pullsCloudStylesOnAppear: Bool = true,
|
||||
learnedStyleGenerator: @escaping LearnedStyleGenerator = { corpus, replyExamples, language in
|
||||
try await PolishStyleLearningService(store: AppGroupStore())
|
||||
.generateStyle(
|
||||
from: corpus,
|
||||
replyExamples: replyExamples,
|
||||
outputLanguage: language,
|
||||
minimumEffectiveCharacterCount:
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
? PolishStyleLearningCorpusBuilder
|
||||
.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder
|
||||
.requiredEffectiveCharacterCount
|
||||
)
|
||||
}
|
||||
pullsCloudStylesOnAppear: Bool = true
|
||||
) {
|
||||
_editingPack = State(initialValue: initialEditingPack)
|
||||
self.pullsCloudStylesOnAppear = pullsCloudStylesOnAppear
|
||||
self.learnedStyleGenerator = learnedStyleGenerator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
if let learnedStylePack {
|
||||
learnedStyleCard(
|
||||
learnedStylePack,
|
||||
corpus: styleLearningCorpus
|
||||
)
|
||||
} else {
|
||||
styleLearningCard
|
||||
}
|
||||
packGridSection(
|
||||
title: "polishStyles.builtin.section",
|
||||
packs: PolishStylePackCatalog.BuiltinStyleGroup.practical.packs
|
||||
@@ -98,7 +71,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.scrollClipDisabled()
|
||||
.background(palette.background)
|
||||
.navigationTitle("polishStyles.title")
|
||||
.navigationTitle(AppL10n.string("polishStyles.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
@@ -108,11 +81,8 @@ struct PolishStylesView: View {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.tint(palette.textPrimary)
|
||||
.disabled(
|
||||
catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
|| isGeneratingLearnedStyle
|
||||
)
|
||||
.accessibilityLabel(Text("polishStyles.add"))
|
||||
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
|
||||
.accessibilityLabel(Text(AppL10n.string("polishStyles.add")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,7 +104,7 @@ struct PolishStylesView: View {
|
||||
set: { if !$0 { errorAlert = nil } }
|
||||
)
|
||||
) {
|
||||
Button("common.done") { errorAlert = nil }
|
||||
Button(AppL10n.string("common.done")) { errorAlert = nil }
|
||||
} message: {
|
||||
Text(errorAlert?.message ?? "")
|
||||
}
|
||||
@@ -150,355 +120,22 @@ struct PolishStylesView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .settingsDidSyncFromCloud)) { _ in
|
||||
reload()
|
||||
}
|
||||
.onDisappear {
|
||||
cancelLearnedStyleGeneration()
|
||||
}
|
||||
}
|
||||
|
||||
private var styleLearningCorpus: PolishStyleLearningCorpus {
|
||||
PolishStyleLearningCorpusBuilder.build(from: history.snapshot())
|
||||
}
|
||||
|
||||
/// Minimum effective character count required to unlock personal style
|
||||
/// generation. Debug + TestFlight builds use a lower 1,250-character
|
||||
/// gate so internal testers can still exercise the pipeline; the
|
||||
/// previous 0 / "unlimited" bypass has been removed.
|
||||
private var styleLearningMinimumCharacterCount: Int {
|
||||
AppDistributionChannel.allowsInternalTools
|
||||
? PolishStyleLearningCorpusBuilder.testBuildEffectiveCharacterCount
|
||||
: PolishStyleLearningCorpusBuilder.requiredEffectiveCharacterCount
|
||||
}
|
||||
|
||||
private func isEligibleForStyleGeneration(
|
||||
_ corpus: PolishStyleLearningCorpus
|
||||
) -> Bool {
|
||||
corpus.effectiveCharacterCount >= styleLearningMinimumCharacterCount
|
||||
}
|
||||
|
||||
private var learnedStylePack: PolishStylePack? {
|
||||
catalog.entries
|
||||
.filter { $0.learningMetadata != nil }
|
||||
.max { $0.updatedAt < $1.updatedAt }
|
||||
}
|
||||
|
||||
/// Distilled personal styles belong to the Skills tab and drive AI replies
|
||||
/// only, so they never appear here — not even under "My styles". Filtering
|
||||
/// by `isPersonalReplyStyle` rather than by "newest learned pack" also keeps
|
||||
/// older generations from leaking in as if they were hand-written.
|
||||
private var remainingUserPacks: [PolishStylePack] {
|
||||
let featuredID = learnedStylePack?.id
|
||||
return PolishStylePackCatalog.all(userCatalog: catalog)
|
||||
.filter { $0.kind == .user && $0.id != featuredID }
|
||||
}
|
||||
|
||||
private var styleLearningCard: some View {
|
||||
let corpus = styleLearningCorpus
|
||||
let required = styleLearningMinimumCharacterCount
|
||||
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
|
||||
let isActionAvailable = isEligibleForStyleGeneration(corpus) && !reachedLimit
|
||||
let canGenerate = isActionAvailable && !isGeneratingLearnedStyle
|
||||
let completedCharacterCount = min(corpus.effectiveCharacterCount, required)
|
||||
let learnedFraction = required > 0
|
||||
? Double(completedCharacterCount) / Double(required)
|
||||
: 0
|
||||
let progressDescription = AppL10n.format(
|
||||
"polishStyles.learn.progress",
|
||||
Int64(corpus.effectiveCharacterCount),
|
||||
Int64(required)
|
||||
)
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "brain")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 38, height: 38)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("polishStyles.learn.title")
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("polishStyles.learn.body")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
ProgressTrack(
|
||||
segments: [
|
||||
ProgressTrackSegment(
|
||||
fraction: learnedFraction,
|
||||
color: palette.accent
|
||||
)
|
||||
]
|
||||
)
|
||||
.accessibilityLabel(Text("polishStyles.learn.title"))
|
||||
.accessibilityValue(Text(progressDescription))
|
||||
|
||||
HStack {
|
||||
Text(progressDescription)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(
|
||||
isEligibleForStyleGeneration(corpus)
|
||||
? AppL10n.string("polishStyles.learn.ready")
|
||||
: AppL10n.format(
|
||||
"polishStyles.learn.remaining",
|
||||
Int64(
|
||||
max(
|
||||
0,
|
||||
styleLearningMinimumCharacterCount
|
||||
- corpus.effectiveCharacterCount
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(
|
||||
isEligibleForStyleGeneration(corpus)
|
||||
? palette.accent
|
||||
: palette.textTertiary
|
||||
)
|
||||
}
|
||||
|
||||
Button {
|
||||
generateLearnedStyle(from: corpus)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isGeneratingLearnedStyle {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.tint(isActionAvailable ? palette.textOnAccent : palette.textSecondary)
|
||||
} else {
|
||||
Image(systemName: "sparkles")
|
||||
}
|
||||
Text(
|
||||
isGeneratingLearnedStyle
|
||||
? AppL10n.string("polishStyles.learn.generating")
|
||||
: AppL10n.string("polishStyles.learn.action")
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(
|
||||
isActionAvailable ? palette.textOnAccent : palette.textSecondary
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 48)
|
||||
.background(
|
||||
isActionAvailable ? palette.accent : palette.surfaceElevated,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canGenerate)
|
||||
.accessibilityIdentifier("polishStyles.learn.generate")
|
||||
|
||||
Text(
|
||||
reachedLimit
|
||||
? AppL10n.string("polishStyles.learn.limit")
|
||||
: AppL10n.string("polishStyles.learn.privacy")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
private func learnedStyleCard(
|
||||
_ pack: PolishStylePack,
|
||||
corpus: PolishStyleLearningCorpus
|
||||
) -> some View {
|
||||
let isSelected = pack.id == activeID
|
||||
let canRegenerate = isEligibleForStyleGeneration(corpus)
|
||||
&& !isGeneratingLearnedStyle
|
||||
let hasInsufficientEvidence = Self.hasInsufficientEvidence(pack.learningMetadata)
|
||||
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
||||
let actionShape = RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
let selectedFillColors = colorScheme == .dark
|
||||
? [
|
||||
OSGColor.selectedCardFillLeadingDark,
|
||||
OSGColor.selectedCardFillTrailingDark
|
||||
]
|
||||
: [
|
||||
OSGColor.selectedCardFillLeadingLight,
|
||||
OSGColor.selectedCardFillTrailingLight
|
||||
]
|
||||
let selectedStroke = colorScheme == .dark
|
||||
? OSGColor.selectedCardStrokeDark
|
||||
: OSGColor.selectedCardStrokeLight
|
||||
|
||||
return VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
HStack(alignment: .top, spacing: Spacing.md) {
|
||||
Image(systemName: "person.wave.2.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
palette.textPrimary.opacity(0.08),
|
||||
in: RoundedRectangle(
|
||||
cornerRadius: Radius.medium,
|
||||
style: .continuous
|
||||
)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(pack.displayName(language: config.uiLanguage))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(
|
||||
hasInsufficientEvidence
|
||||
? AppL10n.string("polishStyles.learn.lowConfidence")
|
||||
: AppL10n.string("polishStyles.learn.generated.description")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(
|
||||
hasInsufficientEvidence
|
||||
? palette.danger
|
||||
: palette.textSecondary
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
editingPack = pack
|
||||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(palette.surfaceElevated, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
.accessibilityLabel(Text("polishStyles.edit"))
|
||||
}
|
||||
|
||||
if let metadata = pack.learningMetadata {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "clock")
|
||||
Text("polishStyles.learn.generatedAt")
|
||||
Text(
|
||||
metadata.generatedAt,
|
||||
format: .dateTime.year().month().day()
|
||||
)
|
||||
}
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.evidenceSummary",
|
||||
Int64(metadata.asrEffectiveCharacterCount),
|
||||
Int64(metadata.replyExampleCount),
|
||||
Int64(metadata.replyFinalEditCount)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.confidence",
|
||||
Self.confidencePercentage(metadata.confidence)
|
||||
)
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isSelected {
|
||||
Label(
|
||||
"polishStyles.learn.selected",
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.accent)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.accentMuted, in: actionShape)
|
||||
} else {
|
||||
Button {
|
||||
activate(pack)
|
||||
} label: {
|
||||
Label(
|
||||
"polishStyles.learn.select",
|
||||
systemImage: "checkmark.circle"
|
||||
)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.surfaceElevated, in: actionShape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
generateLearnedStyle(from: corpus, replacing: pack)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if isGeneratingLearnedStyle {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
}
|
||||
Text(
|
||||
isGeneratingLearnedStyle
|
||||
? "polishStyles.learn.regenerating"
|
||||
: "polishStyles.learn.regenerate"
|
||||
)
|
||||
}
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(
|
||||
canRegenerate
|
||||
? palette.textPrimary
|
||||
: palette.textTertiary
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 44)
|
||||
.background(palette.surfaceElevated, in: actionShape)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canRegenerate)
|
||||
.accessibilityIdentifier("polishStyles.learn.regenerate")
|
||||
.accessibilityValue(Text(pack.id))
|
||||
}
|
||||
}
|
||||
.padding(Spacing.lg)
|
||||
.background {
|
||||
shape
|
||||
.fill(palette.surface)
|
||||
.overlay {
|
||||
if isSelected {
|
||||
shape
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: selectedFillColors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay(
|
||||
shape.stroke(selectedStroke, lineWidth: 0.75)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(shape)
|
||||
.cardElevation(accented: isSelected)
|
||||
.accessibilityIdentifier("polishStyles.learnedStyle.card")
|
||||
PolishStylePackCatalog.all(userCatalog: catalog)
|
||||
.filter { $0.kind == .user && !PolishStylePackCatalog.isPersonalReplyStyle($0) }
|
||||
}
|
||||
|
||||
private func packGridSection(
|
||||
title: LocalizedStringKey,
|
||||
title: String,
|
||||
packs: [PolishStylePack]
|
||||
) -> some View {
|
||||
CardSection(title) {
|
||||
CardSection(title: AppL10n.string(title)) {
|
||||
LazyVGrid(columns: columns, spacing: CardLayoutMetrics.compactItemSpacing) {
|
||||
ForEach(packs) { pack in
|
||||
packCard(pack)
|
||||
@@ -555,8 +192,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
.padding(Spacing.sm)
|
||||
.buttonStyle(.plain)
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
.accessibilityLabel(Text("polishStyles.edit"))
|
||||
.accessibilityLabel(Text(AppL10n.string("polishStyles.edit")))
|
||||
|
||||
if isSelected {
|
||||
CatalogCardChrome.checkIcon(palette: palette)
|
||||
@@ -582,84 +218,18 @@ struct PolishStylesView: View {
|
||||
.clipShape(shape)
|
||||
.cardElevation(accented: isSelected)
|
||||
.contextMenu {
|
||||
Button("polishStyles.duplicate") {
|
||||
Button(AppL10n.string("polishStyles.duplicate")) {
|
||||
duplicate(pack)
|
||||
}
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
if pack.kind == .user {
|
||||
Button("common.delete", role: .destructive) {
|
||||
Button(AppL10n.string("common.delete"), role: .destructive) {
|
||||
delete(pack)
|
||||
}
|
||||
.disabled(isGeneratingLearnedStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateLearnedStyle(
|
||||
from corpus: PolishStyleLearningCorpus,
|
||||
replacing existingPack: PolishStylePack? = nil
|
||||
) {
|
||||
guard isEligibleForStyleGeneration(corpus),
|
||||
!isGeneratingLearnedStyle,
|
||||
learnedStyleGenerationTask == nil else { return }
|
||||
let generationID = UUID()
|
||||
learnedStyleGenerationID = generationID
|
||||
isGeneratingLearnedStyle = true
|
||||
learnedStyleGenerationTask = Task { @MainActor in
|
||||
defer { finishLearnedStyleGeneration(id: generationID) }
|
||||
do {
|
||||
let generated = try await learnedStyleGenerator(
|
||||
corpus,
|
||||
ClipboardReplyFeedbackStore.shared.learningExamples(),
|
||||
config.uiLanguage
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard learnedStyleGenerationID == generationID,
|
||||
editingPack == nil,
|
||||
viewingPack == nil else { return }
|
||||
// Always let the user inspect and edit the learned prompt before
|
||||
// it is saved, synced, or made active.
|
||||
if let existingPack {
|
||||
editingPack = PolishStylePack(
|
||||
id: existingPack.id,
|
||||
name: generated.name,
|
||||
prompt: generated.prompt,
|
||||
allowsAddedEmoji: generated.allowsAddedEmoji,
|
||||
learningMetadata: generated.learningMetadata,
|
||||
kind: .user,
|
||||
createdAt: existingPack.createdAt,
|
||||
updatedAt: Date()
|
||||
)
|
||||
} else {
|
||||
editingPack = generated
|
||||
}
|
||||
} catch {
|
||||
guard learnedStyleGenerationID == generationID,
|
||||
!Task.isCancelled,
|
||||
!Self.isCancellation(error) else { return }
|
||||
errorAlert = PolishStyleErrorAlert(
|
||||
title: AppL10n.string("polishStyles.learn.error.title"),
|
||||
message: localizedLearningError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelLearnedStyleGeneration() {
|
||||
learnedStyleGenerationTask?.cancel()
|
||||
learnedStyleGenerationTask = nil
|
||||
learnedStyleGenerationID = nil
|
||||
isGeneratingLearnedStyle = false
|
||||
}
|
||||
|
||||
private func finishLearnedStyleGeneration(id: UUID) {
|
||||
guard learnedStyleGenerationID == id else { return }
|
||||
learnedStyleGenerationTask = nil
|
||||
learnedStyleGenerationID = nil
|
||||
isGeneratingLearnedStyle = false
|
||||
}
|
||||
|
||||
private func descriptionKey(for pack: PolishStylePack) -> LocalizedStringKey {
|
||||
private func descriptionKey(for pack: PolishStylePack) -> String {
|
||||
guard pack.kind == .builtin else { return "polishStyles.custom.description" }
|
||||
switch pack.id {
|
||||
case "builtin.structured": return "polishStyles.structured.description"
|
||||
@@ -710,7 +280,6 @@ struct PolishStylesView: View {
|
||||
}
|
||||
|
||||
private func duplicate(_ pack: PolishStylePack) {
|
||||
guard !isGeneratingLearnedStyle else { return }
|
||||
guard catalog.entries.count < PolishStyleLimits.maximumUserPacks else {
|
||||
errorAlert = PolishStyleErrorAlert(
|
||||
title: AppL10n.string("polishStyles.error.title"),
|
||||
@@ -736,7 +305,7 @@ struct PolishStylesView: View {
|
||||
}
|
||||
|
||||
private func delete(_ pack: PolishStylePack) {
|
||||
guard pack.kind == .user, !isGeneratingLearnedStyle else { return }
|
||||
guard pack.kind == .user else { return }
|
||||
catalog.recordDeletion(of: pack.id)
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
if activeID == pack.id {
|
||||
@@ -749,24 +318,6 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func localizedLearningError(_ error: Error) -> String {
|
||||
switch error as? PolishStyleLearningError {
|
||||
case .insufficientCorpus:
|
||||
return AppL10n.string("polishStyles.learn.error.insufficient")
|
||||
case .invalidResponse:
|
||||
return AppL10n.string("polishStyles.learn.error.invalidResponse")
|
||||
case .promptTooLong:
|
||||
return AppL10n.string("polishStyles.learn.error.promptTooLong")
|
||||
case .requestTooLarge:
|
||||
return AppL10n.string("polishStyles.learn.error.requestTooLarge")
|
||||
case nil:
|
||||
return PolishStyleLearningFailureMessage.localized(
|
||||
for: error,
|
||||
language: config.uiLanguage
|
||||
) ?? AppL10n.string("polishStyles.learn.error.request")
|
||||
}
|
||||
}
|
||||
|
||||
private func localized(_ error: Error) -> String {
|
||||
switch error as? PolishStyleValidationError {
|
||||
case .emptyName: return AppL10n.string("polishStyles.error.emptyName")
|
||||
@@ -778,19 +329,6 @@ struct PolishStylesView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private static func hasInsufficientEvidence(
|
||||
_ metadata: PolishStylePack.LearningMetadata?
|
||||
) -> Bool {
|
||||
metadata?.evidenceStatus.caseInsensitiveCompare("insufficient") == .orderedSame
|
||||
}
|
||||
|
||||
private static func confidencePercentage(_ confidence: Double) -> Int64 {
|
||||
Int64((min(max(confidence, 0), 1) * 100).rounded())
|
||||
}
|
||||
|
||||
private static func isCancellation(_ error: Error) -> Bool {
|
||||
error is CancellationError || (error as? LLMError) == .cancelled
|
||||
}
|
||||
}
|
||||
|
||||
private struct PolishStylePromptDetailSheet: View {
|
||||
@@ -818,7 +356,7 @@ private struct PolishStylePromptDetailSheet: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.done") { dismiss() }
|
||||
Button(AppL10n.string("common.done")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
@@ -826,7 +364,9 @@ private struct PolishStylePromptDetailSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct PolishStyleEditorSheet: View {
|
||||
/// Shared with `PersonalReplyStyleSection`: a generated personal style is
|
||||
/// reviewed in the same editor before it is saved or enabled.
|
||||
struct PolishStyleEditorSheet: View {
|
||||
let pack: PolishStylePack
|
||||
let isNew: Bool
|
||||
let onSave: (PolishStylePack) -> Bool
|
||||
@@ -858,8 +398,8 @@ private struct PolishStyleEditorSheet: View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
Text(
|
||||
Self.hasInsufficientEvidence(metadata)
|
||||
? AppL10n.string("polishStyles.learn.lowConfidence")
|
||||
: AppL10n.string("polishStyles.learn.generated.description")
|
||||
? AppL10n.string("personalReplyStyle.lowConfidence")
|
||||
: AppL10n.string("personalReplyStyle.generated.description")
|
||||
)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(
|
||||
@@ -869,7 +409,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
)
|
||||
Text(
|
||||
AppL10n.format(
|
||||
"polishStyles.learn.confidence",
|
||||
"personalReplyStyle.confidence",
|
||||
Self.confidencePercentage(metadata.confidence)
|
||||
)
|
||||
)
|
||||
@@ -882,17 +422,17 @@ private struct PolishStyleEditorSheet: View {
|
||||
.accessibilityIdentifier("polishStyles.editor.learningEvidence")
|
||||
}
|
||||
}
|
||||
Section("polishStyles.editor.name") {
|
||||
TextField("polishStyles.editor.namePlaceholder", text: $name)
|
||||
Section(AppL10n.string("polishStyles.editor.name")) {
|
||||
TextField(AppL10n.string("polishStyles.editor.namePlaceholder"), text: $name)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
}
|
||||
Section {
|
||||
Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
|
||||
Toggle(AppL10n.string("polishStyles.editor.allowsAddedEmoji"), isOn: $allowsAddedEmoji)
|
||||
.settingsListRow()
|
||||
.cardListRow(elevated: false)
|
||||
} footer: {
|
||||
Text("polishStyles.editor.allowsAddedEmoji.hint")
|
||||
Text(AppL10n.string("polishStyles.editor.allowsAddedEmoji.hint"))
|
||||
}
|
||||
Section {
|
||||
TextEditor(text: $prompt)
|
||||
@@ -911,7 +451,7 @@ private struct PolishStyleEditorSheet: View {
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
Text("polishStyles.editor.prompt")
|
||||
Text(AppL10n.string("polishStyles.editor.prompt"))
|
||||
Spacer()
|
||||
Text("\(prompt.count)/\(PolishStyleLimits.maximumPromptCharacters)")
|
||||
.foregroundStyle(
|
||||
@@ -921,19 +461,19 @@ private struct PolishStyleEditorSheet: View {
|
||||
)
|
||||
}
|
||||
} footer: {
|
||||
Text("polishStyles.editor.hint")
|
||||
Text(AppL10n.string("polishStyles.editor.hint"))
|
||||
}
|
||||
}
|
||||
.navigationTitle(isNew ? "polishStyles.add" : "polishStyles.edit")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("common.cancel") { dismiss() }
|
||||
Button(AppL10n.string("common.cancel")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
.accessibilityIdentifier("polishStyles.editor.cancel")
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("common.save") {
|
||||
Button(AppL10n.string("common.save")) {
|
||||
let result = PolishStylePack(
|
||||
id: pack.id,
|
||||
name: name,
|
||||
|
||||
@@ -16,7 +16,7 @@ struct PrivacyPolicyView: View {
|
||||
scrollToAnchor: privacyScrollAnchor
|
||||
)
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.privacy.policy")
|
||||
.navigationTitle(AppL10n.string("settings.privacy.policy"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ struct ProviderPickerSection: View {
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ struct ProviderPickerSection: View {
|
||||
|
||||
/// 通义千问 / 智谱 GLM 等支持云端 ASR 热词 API 的提供商。
|
||||
private var personalDictionaryBadge: some View {
|
||||
Text("settings.provider.personalDictionaryBadge")
|
||||
Text(AppL10n.string("settings.provider.personalDictionaryBadge"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
@@ -114,7 +114,7 @@ struct ProviderPickerSection: View {
|
||||
|
||||
/// Bailian / Volcengine / OpenAI Realtime — utterance-level true streaming.
|
||||
private var streamingBadge: some View {
|
||||
Text("settings.provider.streamingBadge")
|
||||
Text(AppL10n.string("settings.provider.streamingBadge"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
|
||||
@@ -80,16 +80,18 @@ struct PolishStylesScreenshotHarness: View {
|
||||
delaysGeneration: delaysServiceBackedGeneration
|
||||
)
|
||||
} else if simulatesGeneration {
|
||||
PolishStylesView(
|
||||
learnedStyleGenerator: { _, _, language in
|
||||
PersonalReplyStyleHost(
|
||||
generator: { _, _, language in
|
||||
try await Task.sleep(for: .seconds(1.8))
|
||||
return ReleaseNotesScreenshotFixture.generatedStyle(
|
||||
language: language
|
||||
)
|
||||
}
|
||||
)
|
||||
} else if let generatedPack {
|
||||
PersonalReplyStyleHost(initialEditingPack: generatedPack)
|
||||
} else {
|
||||
PolishStylesView(initialEditingPack: generatedPack)
|
||||
PersonalReplyStyleHost()
|
||||
}
|
||||
}
|
||||
.environment(\.locale, language.swiftUILocale)
|
||||
@@ -112,9 +114,8 @@ private struct PolishStylesServiceUITestHarness: View {
|
||||
var body: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
if showsStyles {
|
||||
PolishStylesView(
|
||||
pullsCloudStylesOnAppear: false,
|
||||
learnedStyleGenerator: { corpus, replyExamples, language in
|
||||
PersonalReplyStyleHost(
|
||||
generator: { corpus, replyExamples, language in
|
||||
let client = PolishStylesUITestScriptedLLMClient(
|
||||
responses: ReleaseNotesScreenshotFixture.serviceResponses(
|
||||
language: language,
|
||||
@@ -168,6 +169,38 @@ private struct PolishStylesServiceUITestHarness: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Personal-style generation moved to the Skills tab when voice input and AI
|
||||
/// replies were split, so these hosts drive the section directly instead of the
|
||||
/// Styles page.
|
||||
@MainActor
|
||||
private struct PersonalReplyStyleHost: View {
|
||||
let initialEditingPack: PolishStylePack?
|
||||
let generator: LearnedStyleGenerator?
|
||||
|
||||
init(
|
||||
initialEditingPack: PolishStylePack? = nil,
|
||||
generator: LearnedStyleGenerator? = nil
|
||||
) {
|
||||
self.initialEditingPack = initialEditingPack
|
||||
self.generator = generator
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
if let generator {
|
||||
PersonalReplyStyleSection(
|
||||
initialEditingPack: initialEditingPack,
|
||||
generator: generator
|
||||
)
|
||||
} else {
|
||||
PersonalReplyStyleSection(initialEditingPack: initialEditingPack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class PolishStylesUITestRecorder: ObservableObject {
|
||||
@Published var didObserveCancellation = false
|
||||
@@ -237,6 +270,7 @@ struct HomeDictionaryScreenshotHarness: View {
|
||||
language = ReleaseNotesScreenshotFixture.language
|
||||
ProviderConfig.shared.uiLanguage = language
|
||||
ReleaseNotesScreenshotFixture.seedFrequentTerms(language: language)
|
||||
ReleaseNotesScreenshotFixture.seedMonthlyUsage()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -412,13 +446,47 @@ private enum ReleaseNotesScreenshotFixture {
|
||||
try? catalog.upsert(pack)
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(catalog)
|
||||
store.setActivePolishStyleId(pack.id)
|
||||
// A distilled pack drives AI replies, not dictation; the voice-style
|
||||
// setter would reject it.
|
||||
store.setPersonalReplyStyleId(pack.id)
|
||||
}
|
||||
|
||||
static func resetStyleCatalog() {
|
||||
let store = AppGroupStore()
|
||||
store.setPolishStyleCatalog(PolishStyleCatalog())
|
||||
store.setActivePolishStyleId(PolishStylePackCatalog.defaultID)
|
||||
store.setPersonalReplyStyleId("")
|
||||
}
|
||||
|
||||
/// Seeds varied per-day dictation totals across the current month so the
|
||||
/// Home monthly-usage calendar renders a realistic spread instead of an
|
||||
/// empty grid. Writes this device's slice only, mirroring `recordUtterance`.
|
||||
static func seedMonthlyUsage() {
|
||||
let store = UsageStatisticsStore.shared
|
||||
let defaults = store.defaults
|
||||
var slice = SyncedUsageStatisticsStorage.currentDeviceSlice(from: defaults)
|
||||
let calendar = Calendar.current
|
||||
let today = Date()
|
||||
let dayOfMonth = calendar.component(.day, from: today)
|
||||
let pattern = [
|
||||
420, 980, 0, 260, 1360, 640, 180, 0, 1120, 540,
|
||||
760, 300, 1500, 220, 880, 0, 640, 1180, 360, 940
|
||||
]
|
||||
var daily: [String: Int] = [:]
|
||||
var total = 0
|
||||
for offset in 0..<dayOfMonth {
|
||||
guard let date = calendar.date(byAdding: .day, value: -offset, to: today)
|
||||
else { continue }
|
||||
let value = pattern[offset % pattern.count]
|
||||
guard value > 0 else { continue }
|
||||
daily[UsageStatisticsDayKey.key(for: date)] = value
|
||||
total += value
|
||||
}
|
||||
slice.dailyDictationCharacters = daily
|
||||
slice.dictationCharacterCount = total
|
||||
slice.updatedAt = today
|
||||
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(slice, defaults: defaults)
|
||||
store.reloadFromDisk()
|
||||
}
|
||||
|
||||
static func seedFrequentTerms(language: AppUILanguage) {
|
||||
|
||||
@@ -24,10 +24,10 @@ struct SettingsICloudSyncRow: View {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: toggleBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.appSettings.iCloudSync.title")
|
||||
Text(AppL10n.string("settings.appSettings.iCloudSync.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.appSettings.iCloudSync.subtitle")
|
||||
Text(AppL10n.string("settings.appSettings.iCloudSync.subtitle"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -45,7 +45,7 @@ struct SettingsICloudSyncRow: View {
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text(syncButtonTitleKey)
|
||||
Text(AppL10n.string(syncButtonTitleKey))
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
if isSyncingNow {
|
||||
@@ -106,7 +106,7 @@ struct SettingsICloudSyncRow: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var syncButtonTitleKey: LocalizedStringKey {
|
||||
private var syncButtonTitleKey: String {
|
||||
if isSyncingNow {
|
||||
return "settings.appSettings.iCloudSync.syncing"
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ struct LocalePickerRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("settings.asrLocale")
|
||||
Text(AppL10n.string("settings.asrLocale"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
@@ -305,7 +305,7 @@ struct LocalePickerRow: View {
|
||||
// On-device badge for the currently selected locale.
|
||||
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
|
||||
Image(systemName: "iphone")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
Text(currentLabel)
|
||||
|
||||
@@ -95,7 +95,7 @@ struct SettingsCredentialRow: View {
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
}
|
||||
|
||||
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
|
||||
private func iconButton(systemName: String, label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
@@ -104,7 +104,7 @@ struct SettingsCredentialRow: View {
|
||||
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityLabel(AppL10n.string(label))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ struct SettingsModelPickerRow: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "chevron.up.chevron.down")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(TypeStyle.caption2.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.frame(width: chevronWidth, height: controlHeight)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
@@ -13,11 +13,11 @@ import SwiftUI
|
||||
struct SettingsNavigationRow: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
private let localizedTitle: LocalizedStringKey?
|
||||
private let localizedTitle: String?
|
||||
private let resolvedTitle: String?
|
||||
var subtitle: String?
|
||||
|
||||
init(title: LocalizedStringKey, subtitle: String? = nil) {
|
||||
init(title: String, subtitle: String? = nil) {
|
||||
self.localizedTitle = title
|
||||
self.resolvedTitle = nil
|
||||
self.subtitle = subtitle
|
||||
@@ -37,7 +37,7 @@ struct SettingsNavigationRow: View {
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
} else if let localizedTitle {
|
||||
Text(localizedTitle)
|
||||
Text(AppL10n.string(localizedTitle))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
@@ -147,10 +147,10 @@ struct AppleASRPickerRow: View {
|
||||
var body: some View {
|
||||
Toggle(isOn: usesAppleRecognition) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.asr.apple.title")
|
||||
Text(AppL10n.string("settings.asr.apple.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.asr.apple.subtitle")
|
||||
Text(AppL10n.string("settings.asr.apple.subtitle"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
@@ -181,7 +181,7 @@ struct SpeechRecognitionSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.asrProvider.title") {
|
||||
CardSection(title: AppL10n.string("settings.asrProvider.title")) {
|
||||
CloudProviderSettingsCard {
|
||||
AppleASRPickerRow(config: config)
|
||||
|
||||
@@ -199,14 +199,14 @@ struct SpeechRecognitionSettingsView: View {
|
||||
}
|
||||
|
||||
if config.engineMode == "local" {
|
||||
CardSection("settings.localEngine.title") {
|
||||
CardSection(title: AppL10n.string("settings.localEngine.title")) {
|
||||
LocalModelsGroup(config: config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.speechRecognition.title")
|
||||
.navigationTitle(AppL10n.string("settings.speechRecognition.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,7 @@ struct TextPolishSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.polishProvider.title") {
|
||||
CardSection(title: AppL10n.string("settings.polishProvider.title")) {
|
||||
CloudProviderSettingsCard {
|
||||
ProviderPickerSection(config: config, role: .polish, showsSurface: false)
|
||||
Divider().background(palette.divider)
|
||||
@@ -230,7 +230,7 @@ struct TextPolishSettingsView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.textPolish.title")
|
||||
.navigationTitle(AppL10n.string("settings.textPolish.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,7 @@ struct GeneralSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.general.appearanceLanguage.title") {
|
||||
CardSection(title: AppL10n.string("settings.general.appearanceLanguage.title")) {
|
||||
VStack(spacing: 0) {
|
||||
AppLanguagePickerRow(
|
||||
selection: Binding(
|
||||
@@ -259,7 +259,7 @@ struct GeneralSettingsView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
CardSection("settings.general.keyboard.title") {
|
||||
CardSection(title: AppL10n.string("settings.general.keyboard.title")) {
|
||||
VStack(spacing: 0) {
|
||||
DefaultInputModePickerRow(
|
||||
selection: $typingConfiguration.defaultInputMode
|
||||
@@ -304,7 +304,7 @@ struct GeneralSettingsView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
CardSection("settings.general.sync.title") {
|
||||
CardSection(title: AppL10n.string("settings.general.sync.title")) {
|
||||
VStack(spacing: 0) {
|
||||
SettingsICloudSyncRow()
|
||||
}
|
||||
@@ -313,7 +313,7 @@ struct GeneralSettingsView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.general.title")
|
||||
.navigationTitle(AppL10n.string("settings.general.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
@@ -327,16 +327,16 @@ struct AIAgentSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.aiAgent.responseLength.section") {
|
||||
CardSection(title: AppL10n.string("settings.aiAgent.responseLength.section")) {
|
||||
VStack(spacing: 0) {
|
||||
AIResponseLengthPickerRow(config: config)
|
||||
Divider().background(palette.divider)
|
||||
Toggle(isOn: $config.multipleReplyVariantsEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.aiAgent.multipleReplies.title")
|
||||
Text(AppL10n.string("settings.aiAgent.multipleReplies.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.aiAgent.multipleReplies.description")
|
||||
Text(AppL10n.string("settings.aiAgent.multipleReplies.description"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
@@ -349,12 +349,58 @@ struct AIAgentSettingsView: View {
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
CardSection(title: AppL10n.string("settings.aiAgent.autoMode.section")) {
|
||||
VStack(spacing: 0) {
|
||||
autoModeToggle(
|
||||
isOn: $config.clipboardAutoModeEnabled,
|
||||
titleKey: "settings.aiAgent.autoMode.title",
|
||||
descriptionKey: "settings.aiAgent.autoMode.description",
|
||||
identifier: "settings.aiAgent.autoMode.toggle"
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
autoModeToggle(
|
||||
isOn: $config.clipboardAutoTranslateEnabled,
|
||||
titleKey: "settings.aiAgent.autoTranslate.title",
|
||||
descriptionKey: "settings.aiAgent.autoTranslate.description",
|
||||
identifier: "settings.aiAgent.autoTranslate.toggle"
|
||||
)
|
||||
Divider().background(palette.divider)
|
||||
autoModeToggle(
|
||||
isOn: $config.clipboardAutoEmailReplyEnabled,
|
||||
titleKey: "settings.aiAgent.autoEmailReply.title",
|
||||
descriptionKey: "settings.aiAgent.autoEmailReply.description",
|
||||
identifier: "settings.aiAgent.autoEmailReply.toggle"
|
||||
)
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.aiAgent.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private func autoModeToggle(
|
||||
isOn: Binding<Bool>,
|
||||
titleKey: String,
|
||||
descriptionKey: String,
|
||||
identifier: String
|
||||
) -> some View {
|
||||
Toggle(isOn: isOn) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text(AppL10n.string(titleKey))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text(AppL10n.string(descriptionKey))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
.accessibilityIdentifier(identifier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Clipboard
|
||||
@@ -365,60 +411,41 @@ struct ClipboardSettingsView: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@ObservedObject private var history = ClipboardHistoryStore.shared
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
@State private var pasteAccessNeedsRecovery = false
|
||||
@State private var showPasteNoTextAlert = false
|
||||
@StateObject private var pasteAccess = PasteAccessController()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.clipboard.section") {
|
||||
CardSection(title: AppL10n.string("settings.clipboard.section")) {
|
||||
VStack(spacing: 0) {
|
||||
Toggle(isOn: $config.clipboardHistoryEnabled) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.history.title")
|
||||
Text(AppL10n.string("settings.clipboard.history.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.history.footer")
|
||||
Text(AppL10n.string("settings.clipboard.history.footer"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.settingsListRow()
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Toggle(isOn: clipboardCandidateBinding) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("settings.clipboard.candidate.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("settings.clipboard.candidate.footer")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
}
|
||||
.tint(palette.accent)
|
||||
.disabled(!config.clipboardHistoryEnabled)
|
||||
.opacity(config.clipboardHistoryEnabled ? 1 : 0.45)
|
||||
.settingsListRow()
|
||||
}
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
// iOS asks per read unless the user flips the durable
|
||||
// "Paste from Other Apps" permission to Allow.
|
||||
CardSection("settings.clipboard.paste.section") {
|
||||
CardSection(title: AppL10n.string("settings.clipboard.paste.section")) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.paste.body")
|
||||
Text(AppL10n.string("settings.clipboard.paste.body"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
if pasteAccessVerified {
|
||||
if pasteAccess.isVerified {
|
||||
Label(
|
||||
"settings.clipboard.paste.verified",
|
||||
AppL10n.string("settings.clipboard.paste.verified"),
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
@@ -429,11 +456,11 @@ struct ClipboardSettingsView: View {
|
||||
}
|
||||
.settingsListRow()
|
||||
|
||||
if !pasteAccessVerified {
|
||||
if !pasteAccess.isVerified {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
verifyPasteAccess()
|
||||
pasteAccess.verify()
|
||||
} label: {
|
||||
SettingsNavigationRow(
|
||||
titleText: AppL10n.string(
|
||||
@@ -445,7 +472,7 @@ struct ClipboardSettingsView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("settings.clipboard.paste.verify")
|
||||
|
||||
if pasteAccessNeedsRecovery {
|
||||
if pasteAccess.needsRecovery {
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Button {
|
||||
@@ -466,9 +493,9 @@ struct ClipboardSettingsView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
CardSection("settings.clipboard.storage.section") {
|
||||
CardSection(title: AppL10n.string("settings.clipboard.storage.section")) {
|
||||
VStack(spacing: 0) {
|
||||
Text("settings.clipboard.storage.body")
|
||||
Text(AppL10n.string("settings.clipboard.storage.body"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -480,7 +507,7 @@ struct ClipboardSettingsView: View {
|
||||
showClearConfirmation = true
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.clipboard.clear.button")
|
||||
Text(AppL10n.string("settings.clipboard.clear.button"))
|
||||
.font(TypeStyle.body)
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "trash")
|
||||
@@ -501,69 +528,34 @@ struct ClipboardSettingsView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle(AppL10n.string("settings.clipboard.title", language: config.uiLanguage))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.confirmationDialog(
|
||||
"settings.clipboard.clear.title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
.alert(
|
||||
AppL10n.string("settings.clipboard.clear.title"),
|
||||
isPresented: $showClearConfirmation
|
||||
) {
|
||||
Button("settings.clipboard.clear.confirm", role: .destructive) {
|
||||
Button(AppL10n.string("common.cancel"), role: .cancel) {}
|
||||
Button(AppL10n.string("settings.clipboard.clear.confirm"), role: .destructive) {
|
||||
history.clearAll()
|
||||
ClipboardReplyFeedbackStore.shared.clear()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("settings.clipboard.clear.message")
|
||||
Text(AppL10n.string("settings.clipboard.clear.message"))
|
||||
}
|
||||
.alert(
|
||||
AppL10n.string("clipboard.paste.noText.title", language: config.uiLanguage),
|
||||
isPresented: $showPasteNoTextAlert
|
||||
isPresented: $pasteAccess.showNoTextAlert
|
||||
) {
|
||||
Button("common.done") { showPasteNoTextAlert = false }
|
||||
Button(AppL10n.string("common.done")) { pasteAccess.showNoTextAlert = false }
|
||||
} message: {
|
||||
Text(AppL10n.string("clipboard.paste.noText.message", language: config.uiLanguage))
|
||||
}
|
||||
.onAppear {
|
||||
history.reload()
|
||||
refreshPasteAccessState()
|
||||
pasteAccess.refresh()
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
guard phase == .active else { return }
|
||||
refreshPasteAccessState()
|
||||
pasteAccessNeedsRecovery = false
|
||||
pasteAccess.refresh(clearRecovery: true)
|
||||
}
|
||||
.onChange(of: config.clipboardHistoryEnabled) { _, enabled in
|
||||
if !enabled {
|
||||
config.clipboardCandidateBarEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var clipboardCandidateBinding: Binding<Bool> {
|
||||
Binding(
|
||||
get: { config.clipboardHistoryEnabled && config.clipboardCandidateBarEnabled },
|
||||
set: { config.clipboardCandidateBarEnabled = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private func verifyPasteAccess() {
|
||||
switch AppPermissions.requestPasteAccess() {
|
||||
case .verified:
|
||||
withAnimation(Motion.quick) {
|
||||
pasteAccessVerified = true
|
||||
pasteAccessNeedsRecovery = false
|
||||
}
|
||||
case .noTextAvailable:
|
||||
showPasteNoTextAlert = true
|
||||
case .unavailable:
|
||||
withAnimation(Motion.quick) {
|
||||
pasteAccessVerified = false
|
||||
pasteAccessNeedsRecovery = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPasteAccessState() {
|
||||
pasteAccessVerified = AppPermissions.hasVerifiedPasteAccess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -578,7 +570,7 @@ struct AboutSettingsView: View {
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
CardPageContent {
|
||||
CardSection("settings.analytics.section") {
|
||||
CardSection(title: AppL10n.string("settings.analytics.section")) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Toggle(
|
||||
isOn: Binding(
|
||||
@@ -586,7 +578,7 @@ struct AboutSettingsView: View {
|
||||
set: { analytics.setEnabled($0) }
|
||||
)
|
||||
) {
|
||||
Text("settings.analytics.title")
|
||||
Text(AppL10n.string("settings.analytics.title"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
}
|
||||
@@ -595,7 +587,7 @@ struct AboutSettingsView: View {
|
||||
|
||||
Divider().background(palette.divider)
|
||||
|
||||
Text("settings.analytics.description")
|
||||
Text(AppL10n.string("settings.analytics.description"))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -604,7 +596,7 @@ struct AboutSettingsView: View {
|
||||
.surfaceCard()
|
||||
}
|
||||
|
||||
CardSection("settings.about.title") {
|
||||
CardSection(title: AppL10n.string("settings.about.title")) {
|
||||
VStack(spacing: 0) {
|
||||
Button {
|
||||
var transaction = Transaction()
|
||||
@@ -642,7 +634,7 @@ struct AboutSettingsView: View {
|
||||
openURL(LegalLinks.repositoryURL)
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.link.github")
|
||||
Text(AppL10n.string("settings.link.github"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
@@ -668,7 +660,7 @@ struct AboutSettingsView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.about.title")
|
||||
.navigationTitle(AppL10n.string("settings.about.title"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.onAppear {
|
||||
analytics.refreshEnabledState()
|
||||
|
||||
@@ -75,7 +75,7 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
.background(palette.background)
|
||||
.navigationTitle("settings.title")
|
||||
.navigationTitle(AppL10n.string("settings.title"))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.toolbar {
|
||||
if presentation == .sheet {
|
||||
@@ -155,14 +155,14 @@ struct SettingsView: View {
|
||||
// MARK: - Optional account
|
||||
|
||||
private var accountEntrySection: some View {
|
||||
CardSection("account.settings.section") {
|
||||
CardSection(title: AppL10n.string("account.settings.section")) {
|
||||
Group {
|
||||
switch accountSession.sessionPhase {
|
||||
case .restoring:
|
||||
HStack(spacing: Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(palette.accent)
|
||||
Text("account.settings.restoring")
|
||||
Text(AppL10n.string("account.settings.restoring"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
Spacer()
|
||||
@@ -171,10 +171,10 @@ struct SettingsView: View {
|
||||
case .signedOut:
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("account.signedOut.title")
|
||||
Text(AppL10n.string("account.signedOut.title"))
|
||||
.font(TypeStyle.headline)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("account.signedOut.body")
|
||||
Text(AppL10n.string("account.signedOut.body"))
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -210,14 +210,14 @@ struct SettingsView: View {
|
||||
|
||||
Spacer(minLength: Spacing.xs)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.font(TypeStyle.caption.weight(.semibold))
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
.settingsListRow()
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityLabel(
|
||||
Text(
|
||||
"\(Text("account.settings.signedIn")) \(session.accountID.uuidString)"
|
||||
"\(Text(AppL10n.string("account.settings.signedIn"))) \(session.accountID.uuidString)"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -242,7 +242,7 @@ struct SettingsView: View {
|
||||
// MARK: - Daily (high-frequency)
|
||||
|
||||
private var dailySection: some View {
|
||||
CardSection("settings.daily.title") {
|
||||
CardSection(title: AppL10n.string("settings.daily.title")) {
|
||||
VStack(spacing: 0) {
|
||||
settingsRouteButton(.general, title: "settings.general.title")
|
||||
|
||||
@@ -336,7 +336,7 @@ struct SettingsView: View {
|
||||
|
||||
private func settingsRouteButton(
|
||||
_ route: SettingsRoute,
|
||||
title: LocalizedStringKey,
|
||||
title: String,
|
||||
subtitle: String? = nil
|
||||
) -> some View {
|
||||
Button {
|
||||
@@ -366,7 +366,7 @@ private struct SettingsSheetDismissButton: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
var body: some View {
|
||||
Button("common.done") { dismiss() }
|
||||
Button(AppL10n.string("common.done")) { dismiss() }
|
||||
.tint(palette.textPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// SmartReplyDemoView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// What's New 2.1.0 recording host for the auto-triggered smart-reply surface.
|
||||
// Copying a message surfaces the real `replyVariantsSurface` on the unified
|
||||
// `AIKeyboardView` with Ordinary / Formal / Playful choices. No ASR, no LLM,
|
||||
// no network — the session is scripted straight into the reply-variant state,
|
||||
// so the frame is the same view the shipping keyboard shows.
|
||||
// Launch with `--smart-reply-demo`, optional `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import OSGKeyboardShared
|
||||
import SwiftUI
|
||||
|
||||
struct SmartReplyDemoView: View {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer(minLength: 0)
|
||||
AIKeyboardView(state: state, typing: typing, onInsert: { _ in })
|
||||
.background(Palette.light.background)
|
||||
}
|
||||
.background(OSGColor.demoBackground.ignoresSafeArea())
|
||||
.environment(
|
||||
\.locale,
|
||||
language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans")
|
||||
)
|
||||
.preferredColorScheme(.light)
|
||||
.task { await runTimeline() }
|
||||
}
|
||||
|
||||
/// Copy → brief thinking → the three personalized reply choices reveal and
|
||||
/// hold. Mirrors the shipping auto-reply path, which populates the same
|
||||
/// `aiSession.replyVariants` the coordinator fills from a live request.
|
||||
private func runTimeline() async {
|
||||
prepareState()
|
||||
try? await sleep(1.0)
|
||||
|
||||
let utteranceID = UUID()
|
||||
state.aiSession.beginPreparing(utteranceID: utteranceID)
|
||||
state.aiSession.beginGenerating(question: "", utteranceID: utteranceID)
|
||||
try? await sleep(1.1)
|
||||
|
||||
state.aiSession.receiveReplyVariants(
|
||||
Self.variants(language: language),
|
||||
utteranceID: utteranceID
|
||||
)
|
||||
try? await sleep(6.0)
|
||||
}
|
||||
|
||||
private func prepareState() {
|
||||
config.uiLanguage = language == .en ? .english : .chinese
|
||||
state.surface = .voice
|
||||
state.aiServiceAvailable = true
|
||||
state.micDisabled = false
|
||||
state.layoutWidth = 390
|
||||
state.usesIPadLayoutMetrics = false
|
||||
state.clipboardHistoryEnabled = true
|
||||
state.clipboardSuggestionText = language == .en
|
||||
? "Read the proposal — can you also send me the budget version tomorrow?"
|
||||
: "方案我看了,明天能不能把预算那一版也发我?"
|
||||
state.aiSession.enter()
|
||||
}
|
||||
|
||||
private static func variants(
|
||||
language: WhatsNewDemoScenario.Language
|
||||
) -> [AIReplyVariant] {
|
||||
if language == .en {
|
||||
return [
|
||||
AIReplyVariant(
|
||||
kind: .ordinary,
|
||||
emotion: .neutral,
|
||||
text: "Sure, I'll put the budget version together and send it over tomorrow morning."
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .formal,
|
||||
emotion: .neutral,
|
||||
text: "Received. I'll finalize the budget version tonight and send it to you tomorrow morning."
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .playful,
|
||||
emotion: .playful,
|
||||
text: "You got it — the budget sheet is getting dressed up and will be on your desk first thing!"
|
||||
)
|
||||
]
|
||||
}
|
||||
return [
|
||||
AIReplyVariant(
|
||||
kind: .ordinary,
|
||||
emotion: .neutral,
|
||||
text: "好的,我整理一下预算版,明天上午发你。"
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .formal,
|
||||
emotion: .neutral,
|
||||
text: "收到,预算版本我今晚整理完毕,明日上午发送给您。"
|
||||
),
|
||||
AIReplyVariant(
|
||||
kind: .playful,
|
||||
emotion: .playful,
|
||||
text: "行嘞~预算表这就去梳妆打扮,明早准时上桌!"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private var language: WhatsNewDemoScenario.Language {
|
||||
let prefix = "--whats-new-lang="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return .zh
|
||||
}
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: String(argument.dropFirst(prefix.count))
|
||||
) ?? .zh
|
||||
}
|
||||
|
||||
private func sleep(_ seconds: Double) async throws {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -33,7 +33,7 @@ struct TranslationPickerRow: View {
|
||||
var body: some View {
|
||||
if isVisible {
|
||||
HStack {
|
||||
Text("settings.translation.afterPolish")
|
||||
Text(AppL10n.string("settings.translation.afterPolish"))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Spacer()
|
||||
|
||||
@@ -85,7 +85,7 @@ struct TypingInputSettingsView: View {
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(TypeStyle.footnote.weight(.semibold))
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard transcribes your voice with on-device speech recognition by default. If you explicitly switch to a cloud engine in Settings, recordings are sent to the ASR provider you configure.";
|
||||
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
|
||||
"NSRemindersFullAccessUsageDescription" = "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.";
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription" = "OSGKeyboard adds calendar events only when you choose Extract Events for copied text. It never reads your existing events.";
|
||||
|
||||
@@ -51,12 +51,14 @@
|
||||
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
|
||||
"onboarding.experience.permissions.later" = "Set Up Later";
|
||||
"onboarding.experience.keyboard.title" = "Add the keyboard";
|
||||
"onboarding.experience.keyboard.subtitle" = "Add the keyboard in iOS Settings and turn on Allow Full Access.";
|
||||
"onboarding.experience.keyboard.subtitle" = "Open Settings → Apps → OSGKeyboard → Keyboards, turn on OSGKeyboard, then enable Allow Full Access.";
|
||||
"onboarding.experience.keyboard.ready" = "OSGKeyboard and Full Access are ready";
|
||||
"onboarding.experience.keyboard.fullAccess" = "Full Access connects the keyboard to the host app microphone and optional AI services";
|
||||
"onboarding.experience.keyboard.addedNeedsFullAccess" = "OSGKeyboard is added — now turn on Allow Full Access";
|
||||
"onboarding.experience.keyboard.openAgain" = "Open Settings Again";
|
||||
"onboarding.experience.keyboard.practiceAction" = "Try It Now";
|
||||
"onboarding.experience.keyboard.previewTitle" = "Keyboards";
|
||||
"onboarding.experience.keyboard.fullAccessRow" = "Allow Full Access";
|
||||
"onboarding.experience.keyboardSwitch.title" = "Switch keyboards";
|
||||
"onboarding.experience.keyboardSwitch.subtitle" = "Once the keyboard is active, we’ll automatically verify Full Access.";
|
||||
"onboarding.experience.keyboardSwitch.instruction" = "Tap the field below, hold the globe key, then choose OSGKeyboard.";
|
||||
@@ -67,6 +69,9 @@
|
||||
"onboarding.experience.keyboardSwitch.timeout" = "OSGKeyboard has not appeared yet. Make sure you switched keyboards.";
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "Check Full Access in Settings";
|
||||
"onboarding.experience.keyboardSwitch.waitingAction" = "Waiting for Verification";
|
||||
"onboarding.experience.keyboard.openFailed" = "Settings could not be opened. Go to Settings → Apps → OSGKeyboard → Keyboards manually to finish setup.";
|
||||
"onboarding.experience.keyboardSwitch.continueWithoutFullAccess" = "Continue (Turn On Full Access Later)";
|
||||
"onboarding.experience.keyboardSwitch.skip" = "Skip for Now";
|
||||
"onboarding.experience.practice.skip" = "Skip for Now";
|
||||
"onboarding.experience.practice.translateSample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.replySample" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||
@@ -97,8 +102,8 @@
|
||||
"onboarding.experience.practice.voiceSample" = "Today is Thursday. Where are you planning to go for fun?";
|
||||
"onboarding.experience.practice.translateTitle" = "Try Translate";
|
||||
"onboarding.experience.practice.translateSubtitle" = "Copy the Chinese sample, translate it into English in OSGKeyboard, then insert the result.";
|
||||
"onboarding.experience.practice.replyTitle" = "Try Reply";
|
||||
"onboarding.experience.practice.replySubtitle" = "Copy the sample below, tap Reply in the keyboard, then insert the generated response.";
|
||||
"onboarding.experience.practice.replyTitle" = "Try Auto-Reply";
|
||||
"onboarding.experience.practice.replySubtitle" = "Copy the sample below; back in the keyboard, AI drafts a reply automatically — just insert it.";
|
||||
"onboarding.experience.practice.askAITitle" = "Ask AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "Hold the keyboard microphone and ask a question. Tap it again to stop recording and wait for the answer, then insert the result.";
|
||||
"onboarding.experience.practice.askAIPrompt" = "Try asking: “What’s in the news today?”";
|
||||
@@ -287,11 +292,17 @@
|
||||
"settings.diagnostics.count" = "%d startup failure reports";
|
||||
"settings.diagnostics.empty" = "No startup failures have been saved.";
|
||||
"settings.diagnostics.latest" = "Latest: %@";
|
||||
"settings.diagnostics.processBreakdown" = "Main app %1$d · Keyboard %2$d";
|
||||
"settings.diagnostics.export" = "Export Diagnostic Logs";
|
||||
"settings.diagnostics.clear" = "Clear Diagnostic Logs";
|
||||
"settings.diagnostics.clear.title" = "Clear diagnostic logs?";
|
||||
"settings.diagnostics.clear.message" = "This permanently removes all saved Flow startup failure reports from this device.";
|
||||
"settings.diagnostics.clear.confirm" = "Clear Logs";
|
||||
"settings.diagnostics.upload.section" = "Internal Upload (Beta Builds)";
|
||||
"settings.diagnostics.upload.title" = "Send crash reports automatically";
|
||||
"settings.diagnostics.upload.description" = "Beta builds only. Sends crash and hang reports — including keyboard memory shutdowns, which TestFlight cannot see — so they can be fixed. Never includes audio, transcripts, clipboard content, prompts, or API keys.";
|
||||
"settings.diagnostics.upload.status.off" = "Automatic sending is off. Reports stay on this device until you export them.";
|
||||
"settings.diagnostics.upload.status.pending" = "%d report(s) waiting to send.";
|
||||
"settings.diagnostics.corpus.section" = "Personal Style Training Corpus";
|
||||
"settings.diagnostics.corpus.count" = "%d eligible samples · %d effective characters";
|
||||
"settings.diagnostics.corpus.empty" = "No eligible paired dictations are available.";
|
||||
@@ -299,7 +310,8 @@
|
||||
"settings.diagnostics.corpus.export" = "Export Training Corpus";
|
||||
"settings.diagnostics.corpus.privacy" = "Contains dictated text and prior polish prompts. Nothing is uploaded automatically. Share only with trusted devices or services.";
|
||||
"settings.diagnostics.privacy.section" = "Privacy";
|
||||
"settings.diagnostics.privacy.body" = "Reports stay on this device until you export or clear them. They include startup state, timings, error codes, and memory usage, but never audio, transcripts, clipboard content, prompts, API keys, or account identifiers.";
|
||||
"settings.diagnostics.privacy.body" = "Reports stay on this device until you export or clear them. They include startup state, timings, error codes, memory usage, and crash or hang details, but never audio, transcripts, clipboard content, prompts, API keys, or account identifiers.";
|
||||
"settings.diagnostics.privacy.body.internal" = "This is a beta build, so crash and hang reports are also sent automatically unless you turn that off above. Reports include startup state, timings, error codes, memory usage, and crash or hang details, but never audio, transcripts, clipboard content, prompts, API keys, or account identifiers.";
|
||||
"releaseNotes.title" = "What's New";
|
||||
"releaseNotes.openHint" = "View release notes for this version";
|
||||
"releaseNotes.unavailable.title" = "Cannot load page";
|
||||
@@ -315,14 +327,19 @@
|
||||
"settings.aiAgent.responseLength.title" = "Response length";
|
||||
"settings.aiAgent.multipleReplies.title" = "Provide multiple replies";
|
||||
"settings.aiAgent.multipleReplies.description" = "Show natural, formal, and playful options for ordinary chats. Invitations and tasks always offer separate decisions so AI never chooses your stance.";
|
||||
"settings.aiAgent.autoMode.section" = "Auto mode";
|
||||
"settings.aiAgent.autoMode.title" = "Auto-reply on open";
|
||||
"settings.aiAgent.autoMode.description" = "When you copy a message that reads as replyable and open the keyboard, drafting starts automatically — no need to tap Reply. Requires clipboard history. You still review before sending.";
|
||||
"settings.aiAgent.autoTranslate.title" = "Auto-translate on open";
|
||||
"settings.aiAgent.autoTranslate.description" = "When you copy text in another language and open the keyboard, it's translated automatically and shown for reading. Requires clipboard history.";
|
||||
"settings.aiAgent.autoEmailReply.title" = "Auto-draft email replies";
|
||||
"settings.aiAgent.autoEmailReply.description" = "When you copy an email and open the keyboard, a reply is drafted automatically. Requires clipboard history. You still review before sending.";
|
||||
"settings.clipboard.title" = "Clipboard";
|
||||
"settings.clipboard.section" = "Features";
|
||||
"settings.clipboard.subtitle.on" = "On";
|
||||
"settings.clipboard.subtitle.off" = "Off";
|
||||
"settings.clipboard.history.title" = "History";
|
||||
"settings.clipboard.history.footer" = "Keeps up to 15 copied text items on this device. Off by default.";
|
||||
"settings.clipboard.candidate.title" = "Suggestion strip";
|
||||
"settings.clipboard.candidate.footer" = "Shows your latest copy above the keyboard. Tap to insert.";
|
||||
"settings.clipboard.paste.section" = "System access";
|
||||
"settings.clipboard.paste.body" = "Allows clipboard skills to read text you copy.";
|
||||
"settings.clipboard.paste.request" = "Verify Paste Access";
|
||||
@@ -443,7 +460,6 @@
|
||||
"preview.title" = "Keyboard Preview";
|
||||
"preview.subtitle" = "Tap Start/Stop Recording to test here. On the real keyboard, tap the mic disc to dictate.";
|
||||
"preview.placeholder" = "Type here or tap the record button";
|
||||
"preview.placeholder" = "Type here or tap the record button";
|
||||
"preview.clear" = "Clear text";
|
||||
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"preview.openSettings" = "Settings";
|
||||
@@ -477,8 +493,6 @@
|
||||
"keyboard.placeholder.error" = "Polishing failed";
|
||||
"keyboard.rec" = "REC";
|
||||
"keyboard.space" = "Space";
|
||||
"keyboard.denied.mic" = "Mic denied";
|
||||
"keyboard.denied.speech" = "Speech denied";
|
||||
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
|
||||
"keyboard.tapToTalkA11y" = "Tap to talk";
|
||||
@@ -488,9 +502,6 @@
|
||||
"mode.transcribe" = "Transcribe";
|
||||
"mode.polish" = "Polish";
|
||||
|
||||
/* Locale chip labels (preview stub) */
|
||||
"locale.zh-Hans" = "ZH-Hans";
|
||||
|
||||
/* Locale menu items (settings) */
|
||||
"locale.auto" = "Auto";
|
||||
"locale.zh-Hans" = "Chinese (Simplified)";
|
||||
@@ -590,6 +601,8 @@
|
||||
"skills.action.reinstallShortcut" = "Reinstall Shortcut";
|
||||
"skills.reply.name" = "Reply";
|
||||
"skills.reply.description" = "Understand the copied text and reply naturally in its primary language.";
|
||||
"skills.speakAsMe.name" = "Say It My Way";
|
||||
"skills.speakAsMe.description" = "Rewrite the copied text in your personal reply style, keeping the meaning and changing only how it is said.";
|
||||
"skills.playfulReply.name" = "Playful reply";
|
||||
"skills.playfulReply.description" = "Reply with a short, witty joke that stays kind and appropriate.";
|
||||
"skills.replyInSourceLanguage.name" = "Reply";
|
||||
@@ -628,6 +641,16 @@
|
||||
"skills.organizeList.description" = "Clean up lists, agendas, and steps without adding new tasks.";
|
||||
"skills.extractTodos.name" = "Extract tasks";
|
||||
"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
|
||||
"skills.reminders.resultTitle" = "Reminders";
|
||||
"skills.reminders.resultSuccess" = "Added to Reminders.";
|
||||
"skills.reminders.resultDenied" = "OSGKeyboard needs Reminders access to add your to-dos. Turn it on in Settings.";
|
||||
"skills.reminders.resultFailed" = "Couldn't add the reminders. Please try again.";
|
||||
"skills.reminders.openSettings" = "Open Settings";
|
||||
"skills.events.resultTitle" = "Calendar";
|
||||
"skills.events.resultSuccess" = "Added to Calendar.";
|
||||
"skills.events.resultDenied" = "OSGKeyboard needs Calendar access to add your events. Turn it on in Settings.";
|
||||
"skills.events.resultFailed" = "Couldn't add the events. Please try again.";
|
||||
"skills.events.openSettings" = "Open Settings";
|
||||
"skills.extractEvents.name" = "Extract events";
|
||||
"skills.extractEvents.description" = "Extract events from copied text into Calendar.";
|
||||
"skills.saveToNotes.name" = "Save to Notes";
|
||||
@@ -678,27 +701,26 @@
|
||||
"polishStyles.fun.section" = "Fun styles";
|
||||
"polishStyles.custom.section" = "My styles";
|
||||
"polishStyles.intro.title" = "Choose a writing personality";
|
||||
"polishStyles.intro.body" = "The selected style shapes every polished dictation. Your custom styles sync through iCloud when settings sync is enabled.";
|
||||
"polishStyles.learn.title" = "Generate Your Style";
|
||||
"polishStyles.learn.body" = "Learn how you naturally express yourself from past dictation.";
|
||||
"polishStyles.learn.progress" = "%lld / %lld characters";
|
||||
"polishStyles.learn.remaining" = "%lld to go";
|
||||
"polishStyles.learn.ready" = "Ready";
|
||||
"polishStyles.learn.action" = "Generate Style";
|
||||
"polishStyles.learn.generating" = "Generating…";
|
||||
"polishStyles.learn.generated.description" = "Your personal style learned from reviewed evidence.";
|
||||
"polishStyles.learn.lowConfidence" = "Low confidence. The prompt was generated from limited evidence; please review it.";
|
||||
"polishStyles.learn.generatedAt" = "Generated";
|
||||
"polishStyles.learn.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
|
||||
"polishStyles.learn.confidence" = "Confidence: %lld%%";
|
||||
"polishStyles.learn.select" = "Use this style";
|
||||
"polishStyles.learn.selected" = "In use";
|
||||
"polishStyles.learn.regenerate" = "Regenerate";
|
||||
"polishStyles.learn.regenerating" = "Regenerating…";
|
||||
"polishStyles.learn.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"polishStyles.learn.limit" = "Delete a custom style before generating another one.";
|
||||
"polishStyles.learn.error.title" = "Couldn’t Generate Style";
|
||||
"polishStyles.learn.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"polishStyles.intro.body" = "The selected style shapes polished dictation only. Your custom styles sync through iCloud when settings sync is enabled.";
|
||||
"personalReplyStyle.section" = "Personal reply style";
|
||||
"personalReplyStyle.empty.title" = "Generate Your Reply Style";
|
||||
"personalReplyStyle.empty.body" = "Learn how you naturally express yourself from past dictation, so AI replies read like you wrote them.";
|
||||
"personalReplyStyle.progress" = "%lld / %lld characters";
|
||||
"personalReplyStyle.remaining" = "%lld to go";
|
||||
"personalReplyStyle.ready" = "Ready";
|
||||
"personalReplyStyle.action" = "Generate Style";
|
||||
"personalReplyStyle.generating" = "Generating…";
|
||||
"personalReplyStyle.generated.description" = "Your personal style learned from reviewed evidence.";
|
||||
"personalReplyStyle.lowConfidence" = "Low confidence. The prompt was generated from limited evidence; please review it.";
|
||||
"personalReplyStyle.generatedAt" = "Generated";
|
||||
"personalReplyStyle.evidenceSummary" = "%lld ASR characters · %lld reply choices · %lld final edits";
|
||||
"personalReplyStyle.confidence" = "Confidence: %lld%%";
|
||||
"personalReplyStyle.regenerate" = "Regenerate";
|
||||
"personalReplyStyle.regenerating" = "Regenerating…";
|
||||
"personalReplyStyle.privacy" = "Sent to your configured AI only when you generate. Review and edit before saving.";
|
||||
"personalReplyStyle.limit" = "Delete a custom style before generating another one.";
|
||||
"personalReplyStyle.error.title" = "Couldn’t Generate Style";
|
||||
"personalReplyStyle.error.insufficient" = "Keep dictating until 2,500 effective characters are available.";
|
||||
"polishStyles.learn.error.invalidResponse" = "The AI did not return a valid writing style. Please try again.";
|
||||
"polishStyles.learn.error.promptTooLong" = "The generated prompt exceeded 6,000 characters. Please try again.";
|
||||
"polishStyles.learn.error.requestTooLarge" = "The learning sample is too large for the configured AI service. Shorten unusually long history entries and try again.";
|
||||
@@ -785,7 +807,6 @@
|
||||
"settings.appSettings.iCloudSync.synced" = "Synced";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "Could not sync settings with iCloud. Try again later.";
|
||||
|
||||
|
||||
/* Polish intensity */
|
||||
"settings.polishIntensity.title" = "Polish intensity";
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
"NSMicrophoneUsageDescription" = "OSGKeyboard 使用麦克风进行语音听写,包括你在其他 App 中输入时保持启用的 Flow 会话。";
|
||||
"NSSpeechRecognitionUsageDescription" = "OSGKeyboard 默认使用设备端语音识别将你的语音转为文字。若你在设置中主动切换到云端引擎,录音会发送到你配置的识别服务商。";
|
||||
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
|
||||
"NSRemindersFullAccessUsageDescription" = "仅当你为复制的文本选择“提取待办”时,OSGKeyboard 才会添加提醒事项。";
|
||||
"NSCalendarsWriteOnlyAccessUsageDescription" = "仅当你为复制的文本选择“提取日程”时,OSGKeyboard 才会添加日历事件,不会读取你已有的日程。";
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
|
||||
"onboarding.experience.intro.eyebrow" = "AI AGENT 语音输入法";
|
||||
"onboarding.experience.intro.title" = "开口,即成文";
|
||||
"onboarding.experience.intro.subtitle" = "最好的语音输入法,最好的贴身 AI Agent";
|
||||
"onboarding.experience.intro.subtitle" = "能听懂、会润色,还能帮你回复";
|
||||
"onboarding.experience.intro.action" = "开始设置";
|
||||
"onboarding.experience.promise.voice.title" = "智能语音输入法";
|
||||
"onboarding.experience.promise.voice.body" = "6 倍输入速度,各种润色方式";
|
||||
@@ -51,34 +51,39 @@
|
||||
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
|
||||
"onboarding.experience.permissions.later" = "暂时跳过";
|
||||
"onboarding.experience.keyboard.title" = "添加键盘";
|
||||
"onboarding.experience.keyboard.subtitle" = "在 iOS 设置中添加键盘,并打开「允许完全访问」。";
|
||||
"onboarding.experience.keyboard.subtitle" = "打开「设置 → App → OSGKeyboard → 键盘」,开启 OSGKeyboard,再打开「允许完全访问」。";
|
||||
"onboarding.experience.keyboard.ready" = "OSGKeyboard 与完全访问均已就绪";
|
||||
"onboarding.experience.keyboard.fullAccess" = "完全访问用于连接主 App 的麦克风和可选 AI 服务";
|
||||
"onboarding.experience.keyboard.addedNeedsFullAccess" = "已检测到 OSGKeyboard,还需要打开「允许完全访问」";
|
||||
"onboarding.experience.keyboard.openAgain" = "再次打开设置";
|
||||
"onboarding.experience.keyboard.practiceAction" = "开始体验";
|
||||
"onboarding.experience.keyboard.previewTitle" = "键盘";
|
||||
"onboarding.experience.keyboard.fullAccessRow" = "允许完全访问";
|
||||
"onboarding.experience.keyboardSwitch.title" = "切换键盘";
|
||||
"onboarding.experience.keyboardSwitch.subtitle" = "激活键盘后,我们会自动验证是否已开启完整访问。";
|
||||
"onboarding.experience.keyboardSwitch.instruction" = "点击下方输入框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.keyboardSwitch.subtitle" = "激活键盘后,我们会自动验证是否已开启完全访问。";
|
||||
"onboarding.experience.keyboardSwitch.instruction" = "轻点下方输入框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.keyboardSwitch.placeholder" = "在这里切换并验证键盘";
|
||||
"onboarding.experience.keyboardSwitch.waiting" = "等待 OSGKeyboard 出现…";
|
||||
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard 与完整访问均已验证";
|
||||
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "已检测到 OSGKeyboard,但尚未检测到完整访问";
|
||||
"onboarding.experience.keyboardSwitch.ready" = "OSGKeyboard 与完全访问均已验证";
|
||||
"onboarding.experience.keyboardSwitch.fullAccessMissing" = "已检测到 OSGKeyboard,但尚未检测到完全访问";
|
||||
"onboarding.experience.keyboardSwitch.timeout" = "尚未检测到 OSGKeyboard,请确认已完成切换";
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
|
||||
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完全访问";
|
||||
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
|
||||
"onboarding.experience.practice.skip" = "暂时跳过体验";
|
||||
"onboarding.experience.keyboard.openFailed" = "无法打开系统设置。请手动前往「设置 → App → OSGKeyboard → 键盘」完成开启。";
|
||||
"onboarding.experience.keyboardSwitch.continueWithoutFullAccess" = "继续(稍后开启完全访问)";
|
||||
"onboarding.experience.keyboardSwitch.skip" = "暂时跳过";
|
||||
"onboarding.experience.practice.skip" = "暂时跳过";
|
||||
"onboarding.experience.practice.translateSample" = "I’ll arrive tomorrow afternoon and message you when I’m there.";
|
||||
"onboarding.experience.practice.replySample" = "我明天下午到,到了以后给你消息。";
|
||||
"onboarding.experience.practice.editorTitle" = "新消息";
|
||||
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
|
||||
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
|
||||
"onboarding.experience.practice.switchTitle" = "切换键盘";
|
||||
"onboarding.experience.practice.switchSubtitle" = "点一下下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.practice.switchSubtitle" = "轻点下面的文本框,长按地球键,然后选择 OSGKeyboard。";
|
||||
"onboarding.experience.practice.fullAccessTitle" = "开启完全访问";
|
||||
"onboarding.experience.practice.fullAccessSubtitle" = "OSGKeyboard 已启用。打开「允许完全访问」后就能使用语音。";
|
||||
"onboarding.experience.practice.fullAccessAction" = "去设置打开完全访问";
|
||||
"onboarding.experience.practice.cloudTitle" = "开始体验";
|
||||
"onboarding.experience.practice.cloudTitle" = "开始前的说明";
|
||||
"onboarding.experience.practice.cloudSubtitle" = "四项教学无需账号,也不会消耗积分。";
|
||||
"onboarding.experience.practice.cloudBody" = "语音在设备端转写;只有本次教学所需的转写文字、示例文本和问题会发送到 OSG 托管 AI。匿名体验凭证将在教学结束后失效。";
|
||||
"onboarding.experience.practice.cloudAction" = "同意并开始体验";
|
||||
@@ -92,28 +97,28 @@
|
||||
"onboarding.experience.practice.progress.reply" = "3 / 4 · AI 回复";
|
||||
"onboarding.experience.practice.progress.askAI" = "4 / 4 · 询问 AI";
|
||||
"onboarding.experience.practice.speakTitle" = "跟着念一句";
|
||||
"onboarding.experience.practice.speakSubtitle" = "点按键盘上的麦克风,照着下方文字念,说完后再点一次。";
|
||||
"onboarding.experience.practice.speakSubtitle" = "轻点键盘上的麦克风,照着下方文字念,说完后再轻点一次。";
|
||||
"onboarding.experience.practice.readAloud" = "请照着念";
|
||||
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
|
||||
"onboarding.experience.practice.translateTitle" = "试试翻译";
|
||||
"onboarding.experience.practice.translateSubtitle" = "复制下方英文,在 OSGKeyboard 中将它翻译成中文,再把结果插入文本框。";
|
||||
"onboarding.experience.practice.replyTitle" = "试试回复";
|
||||
"onboarding.experience.practice.replySubtitle" = "复制下方示例文字,点击键盘中的「回复」,再插入生成的回复。";
|
||||
"onboarding.experience.practice.replyTitle" = "试试自动回复";
|
||||
"onboarding.experience.practice.replySubtitle" = "复制下方示例文字,回到键盘后 AI 会自动为你起草回复,插入即可。";
|
||||
"onboarding.experience.practice.askAITitle" = "问问 AI";
|
||||
"onboarding.experience.practice.askAISubtitle" = "长按键盘麦克风,说出一个问题;再次点击结束录音并等待回答,然后将结果插入文本框。";
|
||||
"onboarding.experience.practice.askAISubtitle" = "长按麦克风说出问题,再次轻点结束;回答生成后插入即可。";
|
||||
"onboarding.experience.practice.askAIPrompt" = "试着问问:“今天有什么新闻?”";
|
||||
"onboarding.experience.practice.copyAction" = "复制示例文字";
|
||||
"onboarding.experience.practice.copied" = "已复制,请切换到 OSGKeyboard";
|
||||
"onboarding.experience.practice.waiting" = "等待你在键盘中完成这一步…";
|
||||
"onboarding.experience.login.title" = "登录领 1000 积分";
|
||||
"onboarding.experience.login.subtitle" = "登录获赠积分,无需 API Key 即可开始免费使用";
|
||||
"onboarding.experience.login.body" = "首次注册,将免费获取积分,邀请好友,还可以获赠更多";
|
||||
"onboarding.experience.login.body" = "首次注册即送积分,邀请好友再赠更多";
|
||||
"onboarding.experience.login.skip" = "暂不登录";
|
||||
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
|
||||
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
|
||||
"onboarding.experience.complete.verifiedTitle" = "已经准备好了";
|
||||
"onboarding.experience.complete.title" = "已经准备好了";
|
||||
"onboarding.experience.complete.subtitle" = "点击开始使用";
|
||||
"onboarding.experience.complete.subtitle" = "轻点开始使用";
|
||||
"onboarding.experience.complete.dictate" = "语音成文";
|
||||
"onboarding.experience.complete.translate" = "翻译";
|
||||
"onboarding.experience.complete.reply" = "智能回复";
|
||||
@@ -287,11 +292,17 @@
|
||||
"settings.diagnostics.count" = "%d 份启动失败报告";
|
||||
"settings.diagnostics.empty" = "尚未保存启动失败报告。";
|
||||
"settings.diagnostics.latest" = "最近一次:%@";
|
||||
"settings.diagnostics.processBreakdown" = "主 App %1$d 份 · 键盘 %2$d 份";
|
||||
"settings.diagnostics.export" = "导出诊断日志";
|
||||
"settings.diagnostics.clear" = "清除诊断日志";
|
||||
"settings.diagnostics.clear.title" = "清除诊断日志?";
|
||||
"settings.diagnostics.clear.message" = "这将从本机永久删除所有 Flow 会话启动失败报告。";
|
||||
"settings.diagnostics.clear.confirm" = "清除日志";
|
||||
"settings.diagnostics.upload.section" = "内测版自动上传";
|
||||
"settings.diagnostics.upload.title" = "自动发送崩溃报告";
|
||||
"settings.diagnostics.upload.description" = "仅内测版本生效。发送崩溃与卡顿报告(含 TestFlight 看不到的键盘内存回收),以便修复问题。不包含录音、转写文本、剪贴板内容、Prompt 或 API 密钥。";
|
||||
"settings.diagnostics.upload.status.off" = "已关闭自动发送。报告仅保留在本机,除非你手动导出。";
|
||||
"settings.diagnostics.upload.status.pending" = "有 %d 份报告等待发送。";
|
||||
"settings.diagnostics.corpus.section" = "个人风格训练语料";
|
||||
"settings.diagnostics.corpus.count" = "%d 条可用样本 · %d 个有效字符";
|
||||
"settings.diagnostics.corpus.empty" = "暂无符合条件的成对听写语料。";
|
||||
@@ -299,7 +310,8 @@
|
||||
"settings.diagnostics.corpus.export" = "导出训练语料";
|
||||
"settings.diagnostics.corpus.privacy" = "文件包含口述文本和历史润色提示词,不会自动上传。请仅分享给可信设备或服务。";
|
||||
"settings.diagnostics.privacy.section" = "隐私";
|
||||
"settings.diagnostics.privacy.body" = "报告会保留在本机,直到你主动导出或清除。报告仅包含启动状态、耗时、错误码和内存占用,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
|
||||
"settings.diagnostics.privacy.body" = "报告会保留在本机,直到你主动导出或清除。报告仅包含启动状态、耗时、错误码、内存占用以及崩溃或卡顿详情,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
|
||||
"settings.diagnostics.privacy.body.internal" = "当前为内测版本,崩溃与卡顿报告还会自动发送,除非你在上方关闭。报告仅包含启动状态、耗时、错误码、内存占用以及崩溃或卡顿详情,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
|
||||
"releaseNotes.title" = "更新说明";
|
||||
"releaseNotes.openHint" = "查看本版本更新说明";
|
||||
"releaseNotes.unavailable.title" = "无法加载页面";
|
||||
@@ -315,14 +327,19 @@
|
||||
"settings.aiAgent.responseLength.title" = "回复篇幅";
|
||||
"settings.aiAgent.multipleReplies.title" = "提供多种回复";
|
||||
"settings.aiAgent.multipleReplies.description" = "普通聊天提供自然、正式和轻松趣味选项;邀约与任务始终提供不同立场,避免 AI 替你做决定。";
|
||||
"settings.aiAgent.autoMode.section" = "自动模式";
|
||||
"settings.aiAgent.autoMode.title" = "打开键盘自动回复";
|
||||
"settings.aiAgent.autoMode.description" = "复制一段可回复的消息并打开键盘时,自动开始生成回复,无需再点「回复」。需开启剪贴板历史;发送前仍由你确认。";
|
||||
"settings.aiAgent.autoTranslate.title" = "打开键盘自动翻译";
|
||||
"settings.aiAgent.autoTranslate.description" = "复制一段非当前系统语言的文本并打开键盘时,自动翻译并展示结果供阅读。需开启剪贴板历史。";
|
||||
"settings.aiAgent.autoEmailReply.title" = "自动撰写回信";
|
||||
"settings.aiAgent.autoEmailReply.description" = "复制一封邮件并打开键盘时,自动撰写回复内容。需开启剪贴板历史;发送前仍由你确认。";
|
||||
"settings.clipboard.title" = "剪贴板";
|
||||
"settings.clipboard.section" = "功能";
|
||||
"settings.clipboard.subtitle.on" = "已开启";
|
||||
"settings.clipboard.subtitle.off" = "已关闭";
|
||||
"settings.clipboard.history.title" = "历史记录";
|
||||
"settings.clipboard.history.footer" = "在本机保存最近 15 条复制文字,默认关闭。";
|
||||
"settings.clipboard.candidate.title" = "建议条";
|
||||
"settings.clipboard.candidate.footer" = "在键盘上方显示最新复制,轻点即可插入。";
|
||||
"settings.clipboard.paste.section" = "系统授权";
|
||||
"settings.clipboard.paste.body" = "允许剪贴板技能读取你主动复制的文字。";
|
||||
"settings.clipboard.paste.request" = "验证粘贴访问";
|
||||
@@ -441,8 +458,8 @@
|
||||
|
||||
/* Keyboard preview */
|
||||
"preview.title" = "键盘预览";
|
||||
"preview.subtitle" = "点击「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
|
||||
"preview.placeholder" = "试着输入或点击按钮录音";
|
||||
"preview.subtitle" = "轻点「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
|
||||
"preview.placeholder" = "试着输入或轻轻点钮录音";
|
||||
"preview.clear" = "清空";
|
||||
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"preview.openSettings" = "设置";
|
||||
@@ -476,20 +493,15 @@
|
||||
"keyboard.placeholder.error" = "润色失败";
|
||||
"keyboard.rec" = "REC";
|
||||
"keyboard.space" = "空格";
|
||||
"keyboard.denied.mic" = "麦克风被拒绝";
|
||||
"keyboard.denied.speech" = "语音识别被拒绝";
|
||||
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
|
||||
"keyboard.tapToTalkA11y" = "点按说话";
|
||||
"keyboard.tapToTalkA11y" = "轻点说话";
|
||||
|
||||
/* Mode chip labels */
|
||||
"mode.off" = "关闭";
|
||||
"mode.transcribe" = "转写";
|
||||
"mode.polish" = "润色";
|
||||
|
||||
/* Locale chip labels (preview stub) */
|
||||
"locale.zh-Hans" = "简体";
|
||||
|
||||
/* Locale menu items (settings) */
|
||||
"locale.auto" = "跟随系统";
|
||||
"locale.zh-Hans" = "中文(简体)";
|
||||
@@ -540,7 +552,7 @@
|
||||
"home.card.dictionary.empty" = "还没有词条";
|
||||
"home.card.dictionary.smart.subtitle" = "从中英文输入习惯中发现的高频新词";
|
||||
"home.card.dictionary.smart.empty" = "暂无推荐热词";
|
||||
"home.card.dictionary.smart.addHint" = "点击添加常用词到个性词库";
|
||||
"home.card.dictionary.smart.addHint" = "轻点添加常用词到个性词库";
|
||||
"home.card.dictionary.smart.accessibilityHint" = "已使用 %lld 次,轻点两次加入个性词库。";
|
||||
"home.card.dictionary.confirm.title" = "将“%@”加入个性词库?";
|
||||
"home.card.dictionary.confirm.message" = "这个词已使用 %lld 次。加入后也会用于提升语音识别和 AI 润色效果。";
|
||||
@@ -589,6 +601,8 @@
|
||||
"skills.action.reinstallShortcut" = "重新安装捷径";
|
||||
"skills.reply.name" = "回复";
|
||||
"skills.reply.description" = "理解复制内容,并使用原文的主要语言自然回复。";
|
||||
"skills.speakAsMe.name" = "我来说";
|
||||
"skills.speakAsMe.description" = "用你的个人回复风格重写复制的文本,保留原意,只换成你的说法。";
|
||||
"skills.playfulReply.name" = "俏皮回复";
|
||||
"skills.playfulReply.description" = "用简短、有梗但不冒犯的方式接住这句话。";
|
||||
"skills.replyInSourceLanguage.name" = "回复";
|
||||
@@ -627,6 +641,16 @@
|
||||
"skills.organizeList.description" = "整理清单、议程和步骤,不新增任务。";
|
||||
"skills.extractTodos.name" = "提取待办";
|
||||
"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
|
||||
"skills.reminders.resultTitle" = "提醒事项";
|
||||
"skills.reminders.resultSuccess" = "已添加到提醒事项。";
|
||||
"skills.reminders.resultDenied" = "需要「提醒事项」权限才能添加待办,请在设置中开启。";
|
||||
"skills.reminders.resultFailed" = "未能添加提醒事项,请重试。";
|
||||
"skills.reminders.openSettings" = "打开设置";
|
||||
"skills.events.resultTitle" = "日历";
|
||||
"skills.events.resultSuccess" = "已加入日历。";
|
||||
"skills.events.resultDenied" = "OSGKeyboard 需要日历访问权限才能添加日程。请在设置中开启。";
|
||||
"skills.events.resultFailed" = "未能添加日程,请重试。";
|
||||
"skills.events.openSettings" = "打开设置";
|
||||
"skills.extractEvents.name" = "提取日程";
|
||||
"skills.extractEvents.description" = "从复制内容提取日程,写入日历。";
|
||||
"skills.saveToNotes.name" = "存入备忘录";
|
||||
@@ -677,27 +701,26 @@
|
||||
"polishStyles.fun.section" = "趣味风格";
|
||||
"polishStyles.custom.section" = "我的风格";
|
||||
"polishStyles.intro.title" = "选择完整写作人格";
|
||||
"polishStyles.intro.body" = "选中的风格会影响每次听写润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
|
||||
"polishStyles.learn.title" = "生成专属风格";
|
||||
"polishStyles.learn.body" = "从历史听写中学习你的表达习惯。";
|
||||
"polishStyles.learn.progress" = "%lld / %lld 字";
|
||||
"polishStyles.learn.remaining" = "还差 %lld 字";
|
||||
"polishStyles.learn.ready" = "可以生成";
|
||||
"polishStyles.learn.action" = "生成风格";
|
||||
"polishStyles.learn.generating" = "生成中…";
|
||||
"polishStyles.learn.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
|
||||
"polishStyles.learn.lowConfidence" = "低置信度,Prompt 已根据有限语料生成,请审阅。";
|
||||
"polishStyles.learn.generatedAt" = "生成于";
|
||||
"polishStyles.learn.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
|
||||
"polishStyles.learn.confidence" = "置信度:%lld%%";
|
||||
"polishStyles.learn.select" = "使用此风格";
|
||||
"polishStyles.learn.selected" = "使用中";
|
||||
"polishStyles.learn.regenerate" = "重新生成";
|
||||
"polishStyles.learn.regenerating" = "重新生成中…";
|
||||
"polishStyles.learn.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"polishStyles.learn.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"polishStyles.learn.error.title" = "无法生成风格";
|
||||
"polishStyles.learn.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"polishStyles.intro.body" = "在这里选择的风格只影响语音输入的润色。开启设置同步后,自定义风格会通过 iCloud 保存。";
|
||||
"personalReplyStyle.section" = "个人回复风格";
|
||||
"personalReplyStyle.empty.title" = "生成个人回复风格";
|
||||
"personalReplyStyle.empty.body" = "从历史听写中学习你的表达习惯,让 AI 回复读起来像你写的。";
|
||||
"personalReplyStyle.progress" = "%lld / %lld 字";
|
||||
"personalReplyStyle.remaining" = "还差 %lld 字";
|
||||
"personalReplyStyle.ready" = "可以生成";
|
||||
"personalReplyStyle.action" = "生成风格";
|
||||
"personalReplyStyle.generating" = "生成中…";
|
||||
"personalReplyStyle.generated.description" = "根据经过确认的证据学习得到的个人表达风格。";
|
||||
"personalReplyStyle.lowConfidence" = "低置信度,Prompt 已根据有限语料生成,请审阅。";
|
||||
"personalReplyStyle.generatedAt" = "生成于";
|
||||
"personalReplyStyle.evidenceSummary" = "%lld 个 ASR 字符 · %lld 次回复选择 · %lld 次最终改稿";
|
||||
"personalReplyStyle.confidence" = "置信度:%lld%%";
|
||||
"personalReplyStyle.regenerate" = "重新生成";
|
||||
"personalReplyStyle.regenerating" = "重新生成中…";
|
||||
"personalReplyStyle.privacy" = "仅在生成时发送给你配置的 AI,保存前可预览和修改。";
|
||||
"personalReplyStyle.limit" = "请先删除一个自定义风格,再生成新风格。";
|
||||
"personalReplyStyle.error.title" = "无法生成风格";
|
||||
"personalReplyStyle.error.insufficient" = "请继续听写,累积到 2,500 个有效字符后再生成。";
|
||||
"polishStyles.learn.error.invalidResponse" = "AI 没有返回有效的润色风格,请重试。";
|
||||
"polishStyles.learn.error.promptTooLong" = "生成的 Prompt 超过 6,000 个字符,请重试。";
|
||||
"polishStyles.learn.error.requestTooLarge" = "学习语料超过当前 AI 服务的请求上限,请缩短异常过长的历史记录后重试。";
|
||||
@@ -757,7 +780,7 @@
|
||||
"settings.personalDictionary.title" = "个性词库";
|
||||
"settings.personalDictionary.summary" = "AI 润色时必须原样保留的词汇。";
|
||||
"settings.personalDictionary.intro.title" = "关于词库";
|
||||
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
|
||||
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。轻点词条可编辑。";
|
||||
"settings.personalDictionary.empty.title" = "还没有词条";
|
||||
"settings.personalDictionary.empty.body" = "添加个性化词库,提升识别准确性\n词库将通过 iCloud 实现跨设备同步";
|
||||
"settings.personalDictionary.search.prompt" = "搜索词条";
|
||||
@@ -784,7 +807,6 @@
|
||||
"settings.appSettings.iCloudSync.synced" = "已同步";
|
||||
"settings.appSettings.iCloudSync.error.generic" = "无法与 iCloud 同步设置,请稍后重试。";
|
||||
|
||||
|
||||
/* 润色强度 */
|
||||
"settings.polishIntensity.title" = "润色强度";
|
||||
|
||||
@@ -915,7 +937,7 @@
|
||||
"account.purchaseHistory.loading" = "正在加载购买记录…";
|
||||
"account.purchaseHistory.empty" = "暂无购买记录";
|
||||
"account.purchaseHistory.error" = "无法加载购买记录,请重试。";
|
||||
"account.purchaseHistory.loadMoreError" = "加载更多记录失败,点击重试";
|
||||
"account.purchaseHistory.loadMoreError" = "加载更多记录失败,轻点重试";
|
||||
"account.purchaseHistory.balanceAfter" = "入账后 %@";
|
||||
"account.purchaseHistory.status.credited" = "已入账";
|
||||
"account.ledger.section" = "积分记录";
|
||||
|
||||
Reference in New Issue
Block a user