feat(app): add flow diagnostics and refine interface
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
This commit is contained in:
Rocky
2026-08-26 14:40:06 +08:00
parent 1ee032bdb9
commit 39002336c0
78 changed files with 2737 additions and 735 deletions
+7 -1
View File
@@ -7,12 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Flow startup diagnostics**: save a privacy-safe ten-second state trace whenever session startup reaches a terminal failure, with local export and deletion controls in Settings. / **Flow 启动诊断**:会话启动最终失败时保存隐私安全的最近十秒状态轨迹,并在设置中提供本机导出与清除入口。
### Changed
- **Card depth**: add a faint directional long shadow to card surfaces throughout the iOS app in both light and dark appearances. / **卡片层次**:为 iOS App 内的卡片界面统一加入淡雅的定向长投影,并适配浅色与深色外观。
- **Neutral dialog actions**: use adaptive black or white styling for system alerts, confirmation dialogs, and modal actions instead of inheriting the green brand accent. / **中性弹窗操作色**:系统警告、确认弹窗及模态操作改用自适应黑色或白色,不再继承绿色品牌强调色。
- **Monthly usage calendar**: replace the seven-day Home and Mac Dashboard chart with a full current-month calendar whose date-circle opacity reflects each day's dictation characters, placing the Home calendar between its four metrics and Personal Dictionary. / **月度用量日历**:以完整的当月日历替换首页与 Mac 总览的近七天图表,并通过日期圆形背景透明度表示每天的听写字数;首页日历位于四项统计指标与个性词库之间。
- **Home credits and navigation**: move the credit and invitation card from Settings to Home above the usage metrics, explain free signup and referral rewards before sign-in, and open History or Personal Dictionary directly from their metric cards. / **首页积分与导航**:将积分与邀请卡片从设置迁移到首页统计指标上方,登录前明确说明免费注册与邀请奖励,并支持从听写字数和词库指标卡片直接进入历史记录或个性词库。
- **Personal style threshold**: allow generating a learned speaking style after 2,500 effective dictation characters instead of 5,000. / **专属风格门槛**:生成学习型说话风格所需的有效听写字符由 5,000 降至 2,500。
### Fixed
- **Durable Apple account sessions**: persist refresh operation identifiers before token rotation, reliably store and validate Apple user identifiers, require one-time reauthentication for unverifiable legacy sessions, and prevent failed Keychain deletion or concurrent account work from restoring a revoked login. / **可靠 Apple 账号会话**:在令牌轮换前持久化刷新操作标识,可靠存储并验证 Apple 用户标识,为无法验证的旧会话执行一次重新认证,并防止 Keychain 删除失败或并发账号操作恢复已撤销的登录态。
- **Account sign-in presentation**: use the concise reward message consistently and keep a uniform app surface beneath translucent Apple authorization sheets. / **账号登录呈现**:统一使用精简的积分奖励文案,并在半透明 Apple 授权弹窗下保持一致的 App 背景。
- **Recommended hotword details**: label Home recommendations by their actual source, render suggestion chips in adaptive monochrome, remove internal usage counts from dictionary rows, and anchor the iPhone confirmation popover to the selected hotword. / **推荐热词详情**:按真实来源标记首页推荐词,以自适应黑白配色显示推荐标签,移除词库列表中的内部使用次数,并让 iPhone 确认弹窗指向所选热词。
## [2.0.3] - 2026-08-24
@@ -44,7 +51,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Conversational skill replies**: make non-business replies sound like an ordinary person talking with friends or colleagues, allow one fitting emoji when emotion clearly calls for it, keep business replies naturally professional, and apply the active user-owned learned style only as a bounded wording-and-rhythm layer. / **口语化技能回复**:非商务回复采用普通人与朋友、好友或同事交谈的自然口吻,情绪明确时可合理使用一个表情;商务回复保持自然专业,当前用户自有的学习风格仅作为受限的用词与节奏层应用。
### Fixed
- **Durable account sessions**: persist refresh operation identifiers before network rotation, recover safely after crashes or Keychain write failures, and sign out when Apple reports revoked credentials. / **可靠账号会话**:在网络轮换前持久化刷新操作标识,在崩溃或 Keychain 写入失败后安全恢复,并在 Apple 凭据被撤销时退出登录。
- **Personal dictionary aliases**: generate common speech-recognition mistakes for Home term suggestions through the same concurrency-safe save path used by manual entries. / **个性词库易错词**:首页推荐词现在与手动词条共用并发安全的保存流程,并自动生成常见语音误识别写法。
- **Home service setup guidance**: explain whether user-owned API keys are missing, link directly to the relevant provider settings, and offer OSG credits as the no-key alternative. / **首页服务配置引导**:明确说明缺少哪类自备 API Key,直接跳转到对应服务商设置,并提供无需填写 Key 的 OSG 积分方案。
- **Live keyboard configuration guidance**: apply speech-language changes to an already open keyboard and explain when clipboard capture is paused because Full Access is off. / **键盘实时配置引导**:识别语言变更会同步到已打开的键盘,并在“允许完全访问”关闭导致剪贴板采集暂停时给出说明。
+14 -1
View File
@@ -4,6 +4,7 @@
import OSGKeyboardHostSupport
import OSGKeyboardShared
import SwiftUI
import UIKit
@main
struct OSGKeyboardApp: App {
@@ -19,6 +20,11 @@ struct OSGKeyboardApp: App {
init() {
MaterialIconsFont.registerIfNeeded()
// Keep system alert and confirmation actions neutral instead of
// inheriting the app's green AccentColor.
UIView.appearance(
whenContainedInInstancesOf: [UIAlertController.self]
).tintColor = .label
// Deliberately do NOT prepare Custom LM here. App launch already sits
// near ~150 MB RSS in Debug; compiling CLM during onboarding races the
// keyboard extension and gets the host jetsammed (signal 9). Warmup
@@ -29,7 +35,10 @@ struct OSGKeyboardApp: App {
var body: some Scene {
WindowGroup {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
if OnboardingExperienceView.isPreviewEnabled {
OOBEPreviewHarness()
.preferredColorScheme(appearance.colorScheme)
} else if ProcessInfo.processInfo.arguments.contains("--whats-new-host") {
// Approach A: Notes-like host only; real keyboard extension overlays it.
// Also used by `--keyboard-appear-stress=` (pass both flags).
Self.makeWhatsNewHostView()
@@ -43,6 +52,10 @@ struct OSGKeyboardApp: App {
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") {
HomeDictionaryScreenshotHarness()
} else if ProcessInfo.processInfo.arguments.contains("--managed-consent-ui-test") {
ManagedCloudConsentUITestHarness()
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
@@ -10,10 +10,31 @@ import OSGKeyboardShared
enum FlowDiagnostics {
static func log(_ message: String) {
FlowFailureLogStore.shared.record(message)
OSGDiag.log(message, category: "flow")
}
static func logDrain(_ report: FlowCaptureDrainReport) {
FlowPipelineDiagnostics.logDrain(report)
}
@discardableResult
static func persistStartupFailure(
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
}
}
@@ -0,0 +1,271 @@
// 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
}
}
@@ -10,6 +10,7 @@ import AVFoundation
import AVKit
import CoreMedia
import OSGKeyboardHostSupport
import OSGKeyboardShared
import UIKit
/// Why `startAndWait` could not prove an active PiP window.
@@ -174,11 +175,19 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
) async -> FlowPiPStartOutcome {
if isPictureInPictureActive { return .started }
FlowDiagnostics.log(
"PiP startAndWait begin generation=\(generation) "
+ "hostTimeout=\(String(format: "%.2f", hostTimeout)) "
+ "activeTimeout=\(String(format: "%.2f", activeTimeout)) "
+ "supported=\(AVPictureInPictureController.isPictureInPictureSupported()) "
+ hostDiagnosticTag
)
let deadline = Date().addingTimeInterval(activeTimeout)
let supportsPiP = await waitForPictureInPictureSupport(
timeout: min(max(0, deadline.timeIntervalSinceNow), 0.8)
)
guard supportsPiP else {
FlowDiagnostics.log("PiP unavailable: unsupported \(hostDiagnosticTag)")
return .failed(.unsupported)
}
@@ -186,6 +195,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
timeout: min(hostTimeout, max(0, deadline.timeIntervalSinceNow))
)
guard hostReady else {
FlowDiagnostics.log("PiP unavailable: hostNotReady \(hostDiagnosticTag)")
return .failed(.hostNotReady)
}
@@ -193,8 +203,12 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
guard await prepareControllerForStart() else {
stopFramePump()
if lastSystemStartFailure != nil {
FlowDiagnostics.log(
"PiP unavailable: systemRejected \(systemFailureDiagnosticTag)"
)
return .failed(.systemRejected)
}
FlowDiagnostics.log("PiP unavailable: notPossible \(hostDiagnosticTag)")
return .failed(hasHostView ? .notPossible : .hostNotReady)
}
@@ -212,7 +226,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
guard pipController?.isPictureInPicturePossible == true else {
FlowDiagnostics.log(
"PiP generation \(generation) never became possible"
"PiP generation \(generation) never became possible \(hostDiagnosticTag)"
)
return .failed(.notPossible)
}
@@ -262,7 +276,8 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
}
FlowDiagnostics.log(
"PiP startAndWait failed: \(failure) possible=\(pipController?.isPictureInPicturePossible == true) "
+ "requests=\(startRequestCount)"
+ "active=\(pipController?.isPictureInPictureActive == true) "
+ "requests=\(startRequestCount) \(systemFailureDiagnosticTag)"
)
return .failed(failure)
}
@@ -403,6 +418,26 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
return isHostInWindowHierarchy
}
private var hostDiagnosticTag: String {
guard let hostView else {
return "hostAttached=0 hostWindow=0 hostBounds=nil hostAlpha=nil"
}
return String(
format: "hostAttached=1 hostWindow=%d hostBounds=%.1fx%.1f hostAlpha=%.3f",
hostView.window == nil ? 0 : 1,
hostView.bounds.width,
hostView.bounds.height,
hostView.alpha
)
}
private var systemFailureDiagnosticTag: String {
guard let error = lastSystemStartFailure as NSError? else {
return "systemError=nil"
}
return "systemErrorDomain=\(error.domain) systemErrorCode=\(error.code)"
}
private func configureControllerIfNeeded() {
guard pipController == nil else { return }
guard AVPictureInPictureController.isPictureInPictureSupported() else { return }
@@ -604,7 +639,7 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
let phoneInset = CGFloat(22)
let phoneRect = canvas.insetBy(dx: phoneInset, dy: phoneInset)
let phonePath = UIBezierPath(roundedRect: phoneRect, cornerRadius: 28)
context.setStrokeColor(UIColor(red: 0.898, green: 0.906, blue: 0.922, alpha: 1).cgColor)
context.setStrokeColor(UIColor(OSGColor.pictureInPictureOutline).cgColor)
context.setLineWidth(2.5)
context.addPath(phonePath.cgPath)
context.strokePath()
@@ -639,11 +674,11 @@ final class FlowPictureInPictureController: NSObject, FlowPictureInPictureContro
private func drawLogoCard(in context: CGContext, rect: CGRect, tuckProgress: CGFloat) {
let cardPath = UIBezierPath(roundedRect: rect, cornerRadius: 16)
context.setFillColor(UIColor(red: 0.20, green: 0.78, blue: 0.55, alpha: 1).cgColor)
context.setFillColor(UIColor(OSGColor.pictureInPictureAccent).cgColor)
context.addPath(cardPath.cgPath)
context.fillPath()
context.setStrokeColor(UIColor(red: 0.20, green: 0.78, blue: 0.55, alpha: 1).cgColor)
context.setStrokeColor(UIColor(OSGColor.pictureInPictureAccent).cgColor)
context.setLineWidth(1.5)
context.addPath(cardPath.cgPath)
context.strokePath()
@@ -751,6 +786,7 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
guard pictureInPictureController === pipController else { return }
isPictureInPictureActive = true
lastSystemStartFailure = nil
FlowDiagnostics.log("PiP delegate didStart generation=\(generation)")
releaseAudioSessionForLowPowerPiP()
}
@@ -772,7 +808,11 @@ extension FlowPictureInPictureController: @preconcurrency AVPictureInPictureCont
// Sample-buffer fallback may need warm-up retries. VideoCall PiP uses
// the automatic-inline path plus the bounded startAndWait fallback.
lastSystemStartFailure = error
FlowDiagnostics.log("PiP start attempt failed (will retry): \(error.localizedDescription)")
let error = error as NSError
FlowDiagnostics.log(
"PiP start attempt failed (will retry): "
+ "domain=\(error.domain) code=\(error.code)"
)
}
func pictureInPictureController(
+100 -7
View File
@@ -302,6 +302,14 @@ final class FlowSessionManager: ObservableObject {
)
guard AppGroup.isAvailable else {
debug("cannot start flow session: App Group unavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: reason,
attemptCount: 0,
elapsed: 0
)
)
return
}
@@ -396,20 +404,35 @@ final class FlowSessionManager: ObservableObject {
reason: String = "unspecified",
startCapture: Bool = false
) {
OSGDiag.log(
FlowDiagnostics.log(
"activateOnForeground reason=\(reason) startCapture=\(startCapture) "
+ "onboardingDone=\(ProviderConfig.shared.hasCompletedOnboarding) "
+ "\(OSGDiag.memoryTag())",
category: "flow"
+ "\(OSGDiag.memoryTag())"
)
guard AppGroup.isAvailable else {
OSGDiag.log("activateOnForeground aborted reason=appGroupUnavailable", category: "flow")
FlowDiagnostics.log("activateOnForeground aborted reason=appGroupUnavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: "activateOnForeground:\(reason)",
attemptCount: 0,
elapsed: 0
)
)
return
}
guard AppPermissions.flowRequirementsMet else {
OSGDiag.log("activateOnForeground aborted reason=permissions", category: "flow")
FlowDiagnostics.log("activateOnForeground aborted reason=permissions")
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: "activateOnForeground:\(reason)",
attemptCount: 0,
elapsed: 0
)
)
return
}
@@ -422,10 +445,30 @@ final class FlowSessionManager: ObservableObject {
/// User-requested recovery always discards the rejected AVKit generation.
func retryPiPRecovery() {
guard AppGroup.isAvailable else { return }
guard AppGroup.isAvailable else {
FlowDiagnostics.log("manual PiP recovery aborted reason=appGroupUnavailable")
FlowDiagnostics.persistStartupFailure(
reason: "appGroupUnavailable",
context: startupFailureContext(
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
attemptCount: 0,
elapsed: 0
)
)
return
}
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
FlowSessionBridge.setHostReady(false)
FlowDiagnostics.log("manual PiP recovery aborted reason=permissions")
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: FlowPiPActivationTrigger.manualRetry.rawValue,
attemptCount: 0,
elapsed: 0
)
)
return
}
wantsActiveSession = true
@@ -933,6 +976,7 @@ final class FlowSessionManager: ObservableObject {
guard !Task.isCancelled else { return }
traceState("startSessionAsync.begin", extra: "trigger=\(trigger.rawValue)")
sessionWarning = nil
let recoveryStartedAt = Date()
guard AppPermissions.flowRequirementsMet else {
sessionWarning = permissionWarningMessage()
@@ -940,11 +984,20 @@ final class FlowSessionManager: ObservableObject {
traceState("startSessionAsync.blocked", extra: "reason=permissions")
FlowSessionBridge.setHostReady(false)
isColdStartHandoff = false
FlowDiagnostics.persistStartupFailure(
reason: "permissionsMissing",
context: startupFailureContext(
trigger: trigger.rawValue,
attemptCount: 0,
elapsed: Date().timeIntervalSince(recoveryStartedAt)
)
)
return
}
let deadline = Date().addingTimeInterval(FlowPiPRecoveryPolicy.totalBudget)
var lastFailure: FlowPiPStartFailure = .timedOut
var attemptsPerformed = 0
for attempt in 1...FlowPiPRecoveryPolicy.maxAttempts {
guard FlowPiPRecoveryPolicy.canContinue(
@@ -984,6 +1037,7 @@ final class FlowSessionManager: ObservableObject {
let remaining = deadline.timeIntervalSinceNow
guard remaining > 0 else { break }
attemptsPerformed = attempt
pipLifecycleState = isActive
? .recovering(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
: .preparing(attempt: attempt, total: FlowPiPRecoveryPolicy.maxAttempts)
@@ -1001,6 +1055,8 @@ final class FlowSessionManager: ObservableObject {
remainingBudget: deadline.timeIntervalSinceNow
)
)
let recoveryElapsed = FlowPiPRecoveryPolicy.totalBudget
- max(0, deadline.timeIntervalSinceNow)
guard FlowPiPRecoveryPolicy.canContinue(
operation: operation,
currentOperation: pipRecoveryOperation,
@@ -1029,7 +1085,9 @@ final class FlowSessionManager: ObservableObject {
lastFailure = failure
traceState(
"startSessionAsync.failed",
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) attempt=\(attempt) failure=\(failure)"
extra: "reason=pipUnavailable trigger=\(trigger.rawValue) "
+ "attempt=\(attempt) failure=\(failure) "
+ "elapsed=\(String(format: "%.2f", recoveryElapsed))"
)
}
}
@@ -1040,6 +1098,14 @@ final class FlowSessionManager: ObservableObject {
FlowSessionBridge.setHostReady(false)
refreshHostReady()
debug("PiP keep-alive failed after bounded recovery: \(lastFailure)")
FlowDiagnostics.persistStartupFailure(
reason: String(describing: lastFailure),
context: startupFailureContext(
trigger: trigger.rawValue,
attemptCount: attemptsPerformed,
elapsed: Date().timeIntervalSince(recoveryStartedAt)
)
)
}
private func activateFlowSessionAfterPiPProof(duration: TimeInterval?) {
@@ -3539,6 +3605,33 @@ final class FlowSessionManager: ObservableObject {
FlowDiagnostics.log(message)
}
private func startupFailureContext(
trigger: String,
attemptCount: Int,
elapsed: TimeInterval
) -> [String: String] {
let memory = OSGDiag.memorySnapshot()
return [
"trigger": trigger,
"attemptCount": "\(attemptCount)",
"elapsedSeconds": String(format: "%.3f", elapsed),
"appForeground": isAppForeground ? "true" : "false",
"sessionActive": isActive ? "true" : "false",
"sessionIntent": wantsActiveSession ? "true" : "false",
"pipLifecycleState": String(describing: pipLifecycleState),
"pipGeneration": "\(pipController.generation)",
"pipActive": pipController.isPictureInPictureActive ? "true" : "false",
"hostReady": FlowSessionBridge.isHostReady() ? "true" : "false",
"bridgeSessionActive": FlowSessionBridge.isSessionActive() ? "true" : "false",
"heartbeatStalenessSeconds": FlowSessionBridge.heartbeatStaleness()
.map { String(format: "%.3f", $0) } ?? "nil",
"microphonePermission": String(describing: AppPermissions.micStatus),
"speechPermission": String(describing: AppPermissions.speechStatus),
"rssMB": String(format: "%.1f", memory.rssMB),
"physicalFootprintMB": String(format: "%.1f", memory.physFootprintMB)
]
}
private static func safeErrorLogMetadata(_ error: Error) -> String {
"errorCategory=\(String(reflecting: type(of: error))) "
+ "errorBytes=\(error.localizedDescription.utf8.count)"
+45 -9
View File
@@ -67,6 +67,7 @@ struct AIAgentSkillsView: View {
} label: {
Image(systemName: "plus")
}
.tint(palette.textPrimary)
.accessibilityLabel(Text("skills.add"))
}
}
@@ -141,6 +142,7 @@ struct AIAgentSkillsView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: skillCardShape)
.cardElevation()
.accessibilityIdentifier("skills.clipboard.guide")
}
@@ -367,6 +369,7 @@ struct AIAgentSkillsView: View {
.padding(.vertical, Spacing.xs)
.background(palette.surface, in: skillCardShape)
.clipShape(skillCardShape)
.cardElevation()
.contentShape(Rectangle())
}
@@ -600,7 +603,10 @@ private struct SkillDetailView: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.surfaceCard()
.background(
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
)
}
}
@@ -731,11 +737,15 @@ private struct SkillEditorSheet: View {
Form {
Section("skills.editor.name") {
TextField("skills.editor.namePlaceholder", text: $name)
.settingsListRow()
.cardListRow(elevated: false)
}
Section("skills.editor.summary") {
TextField("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") {
Button {
@@ -743,11 +753,12 @@ private struct SkillEditorSheet: View {
} label: {
HStack(spacing: Spacing.md) {
Image(systemName: systemImage)
.font(.system(size: 22, weight: .semibold))
.foregroundStyle(palette.accent)
.frame(width: 44, height: 44)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.symbolRenderingMode(.monochrome)
.frame(width: 38, height: 38)
.background(
palette.accentMuted,
palette.textPrimary.opacity(0.08),
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
VStack(alignment: .leading, spacing: 2) {
@@ -764,11 +775,16 @@ private struct SkillEditorSheet: View {
.foregroundStyle(palette.textTertiary)
}
}
.buttonStyle(.plain)
.settingsListRow()
.cardListRow(elevated: false)
}
Section {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 180)
.padding(Spacing.md)
.cardListRow(elevated: false)
} header: {
HStack {
Text("skills.editor.prompt")
@@ -782,20 +798,33 @@ private struct SkillEditorSheet: View {
}
}
Section {
VStack(spacing: 0) {
TextField("skills.editor.linkPlaceholder", text: $shortcutLink)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
.autocorrectionDisabled()
.settingsListRow()
Divider().background(palette.divider)
TextField("skills.editor.shortcutNamePlaceholder", text: $shortcutName)
.settingsListRow()
if isLookingUp {
Divider().background(palette.divider)
Text("skills.editor.lookingUp")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.settingsListRow()
} else if let lookupMessage {
Divider().background(palette.divider)
Text(lookupMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
.settingsListRow()
}
}
.cardListRow(elevated: false)
} header: {
Text("skills.editor.shortcut")
} footer: {
@@ -803,6 +832,8 @@ private struct SkillEditorSheet: View {
}
Section {
Toggle("skills.editor.thinking", isOn: $thinkingEnabled)
.settingsListRow()
.cardListRow(elevated: false)
} footer: {
Text("skills.editor.thinkingHint")
}
@@ -811,6 +842,8 @@ private struct SkillEditorSheet: View {
Button("common.delete", role: .destructive) {
confirmDelete = true
}
.settingsListRow()
.cardListRow(elevated: false)
}
}
}
@@ -819,6 +852,7 @@ private struct SkillEditorSheet: View {
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
.tint(palette.textPrimary)
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
@@ -830,6 +864,7 @@ private struct SkillEditorSheet: View {
}
}
.disabled(!canSave)
.tint(palette.textPrimary)
}
}
.sheet(isPresented: $showSymbolPicker) {
@@ -970,12 +1005,12 @@ private struct SkillSymbolPicker: View {
} label: {
Image(systemName: symbol)
.font(.system(size: 18, weight: .medium))
.foregroundStyle(
symbol == selection ? palette.textOnAccent : palette.textPrimary
)
.foregroundStyle(palette.textPrimary)
.frame(width: 44, height: 44)
.background(
symbol == selection ? palette.accent : palette.surfaceElevated,
symbol == selection
? palette.textPrimary.opacity(0.12)
: palette.surfaceElevated,
in: RoundedRectangle(cornerRadius: 8, style: .continuous)
)
}
@@ -993,6 +1028,7 @@ private struct SkillSymbolPicker: View {
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
.tint(palette.textPrimary)
}
}
}
@@ -32,7 +32,7 @@ struct AIClipboardSkillLayoutDemoView: View {
)
.background(Palette.light.background)
}
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
.background(OSGColor.demoBackground.ignoresSafeArea())
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
.preferredColorScheme(.light)
.task { await runTimeline() }
@@ -48,7 +48,7 @@ struct AIClipboardSkillLayoutDemoView: View {
VStack(spacing: 10) {
Text("技能布局预览")
.font(.headline)
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
HStack(spacing: 16) {
Button {
count = max(1, count - 1)
@@ -58,7 +58,7 @@ struct AIClipboardSkillLayoutDemoView: View {
}
Text("\(count)")
.font(.title2.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.frame(minWidth: 72)
Button {
count = min(AIClipboardSkillCatalog.catalog.count, count + 1)
@@ -67,14 +67,14 @@ struct AIClipboardSkillLayoutDemoView: View {
.font(.system(size: 28))
}
}
.foregroundStyle(.green)
.foregroundStyle(OSGColor.brandAccent)
HStack(spacing: 8) {
ForEach([3, 8, AIClipboardSkillCatalog.catalog.count], id: \.self) { n in
Button("\(n)") {
count = n
}
.buttonStyle(.borderedProminent)
.tint(count == n ? .green : .gray)
.tint(count == n ? OSGColor.brandAccent : OSGColor.demoSelectionInactive)
}
}
}
+1 -1
View File
@@ -29,7 +29,7 @@ struct AIKeyboardDemoView: View {
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
OSGColor.demoBackground.ignoresSafeArea()
// Both scenes sit on the bottom band so what's-new crop matches Ext chrome.
VStack(spacing: 0) {
Spacer(minLength: 0)
@@ -152,7 +152,8 @@ private enum AccountAppleAuthorizationPayload {
let identityToken = String(data: identityTokenData, encoding: .utf8),
let authorizationCode = String(data: authorizationCodeData, encoding: .utf8),
!identityToken.isEmpty,
!authorizationCode.isEmpty
!authorizationCode.isEmpty,
!credential.user.isEmpty
else {
return nil
}
@@ -13,16 +13,22 @@ import SwiftUI
struct AccountCenterUITestHarness: View {
@StateObject private var coordinator: AccountSessionCoordinator
private let startsSignIn: Bool
private let screenshotLanguage: AppUILanguage?
init() {
let arguments = ProcessInfo.processInfo.arguments
let startsSignIn = ProcessInfo.processInfo.arguments.contains(
"--account-signin-loading"
)
let staysSignedOut = arguments.contains("--account-signed-out")
screenshotLanguage = arguments.contains("--screenshot-lang=en")
? .english
: arguments.contains("--screenshot-lang=zh") ? .chinese : nil
let service = AccountCenterUITestService(
shouldFailSnapshot: ProcessInfo.processInfo.arguments.contains(
shouldFailSnapshot: arguments.contains(
"--account-snapshot-failure"
),
startsSignedOut: startsSignIn,
startsSignedOut: startsSignIn || staysSignedOut,
signInDelay: startsSignIn ? .seconds(5) : .zero
)
self.startsSignIn = startsSignIn
@@ -46,6 +52,7 @@ struct AccountCenterUITestHarness: View {
.environmentObject(coordinator)
}
}
.environment(\.locale, screenshotLanguage?.swiftUILocale ?? Locale.current)
.task {
await coordinator.restoreIfNeeded()
if startsSignIn {
@@ -53,7 +60,8 @@ struct AccountCenterUITestHarness: View {
with: AppleAuthorizationPayload(
identityToken: "ui-test-identity",
authorizationCode: "ui-test-authorization",
nonce: "ui-test-nonce"
nonce: "ui-test-nonce",
userIdentifier: "ui-test-apple-user"
)
)
}
@@ -21,11 +21,17 @@ struct AccountCenterView: View {
var body: some View {
ZStack {
// AuthenticationServices uses translucent system chrome on
// iOS 26. Keep its underlying surface uniform without covering
// the account content that remains visible below the system sheet.
palette.background.ignoresSafeArea()
sessionContent
}
.background(palette.background)
.navigationTitle("account.title")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.task(id: coordinator.accountID) {
guard let accountID = coordinator.accountID else {
coordinator.creditPurchases.reset()
@@ -42,18 +48,6 @@ struct AccountCenterView: View {
.onDisappear {
coordinator.creditPurchases.dismissSuccessMessage()
}
.alert(
"account.error.title",
isPresented: operationErrorBinding
) {
Button("common.done") {
coordinator.dismissOperationError()
}
} message: {
if let key = coordinator.operationErrorKey {
Text(LocalizedStringKey(key))
}
}
.alert(
"account.profile.editTitle",
isPresented: $showProfileEditor
@@ -62,12 +56,14 @@ struct AccountCenterView: View {
.textInputAutocapitalization(.words)
.autocorrectionDisabled()
Button("common.cancel", role: .cancel) {}
.tint(palette.textPrimary)
Button("account.profile.save") {
if case let .signedIn(session) = coordinator.sessionPhase {
saveDisplayName(session: session)
}
}
.disabled(!canSaveCurrentDisplayName)
.tint(palette.textPrimary)
} message: {
Text("account.profile.editMessage")
}
@@ -132,6 +128,8 @@ struct AccountCenterView: View {
.surfaceCard()
}
}
.scrollContentBackground(.hidden)
.background(palette.background)
}
private func signedInContent(session: AccountSession) -> some View {
@@ -143,6 +141,8 @@ struct AccountCenterView: View {
}
.padding(.bottom, Spacing.lg)
}
.scrollContentBackground(.hidden)
.background(palette.background)
.refreshable {
async let accountRefresh: Void = coordinator.refreshAccountData(force: true)
async let catalogRefresh: Void =
@@ -173,6 +173,16 @@ struct AccountCenterView: View {
EmptyView()
}
if let errorKey = coordinator.accountRefreshErrorKey {
AccountStateCard(
systemImage: "exclamationmark.triangle",
titleKey: errorKey,
actionKey: "account.retry"
) {
Task { await coordinator.refreshAccountData(force: true) }
}
}
CardSection("account.storekit.section") {
VStack(spacing: Spacing.sm) {
AccountCreditPurchaseSection(
@@ -257,7 +267,7 @@ struct AccountCenterView: View {
.font(.system(size: 16, weight: .semibold))
}
}
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
.frame(width: 40, height: 40)
.background(palette.surfaceElevated, in: Circle())
}
@@ -267,6 +277,7 @@ struct AccountCenterView: View {
}
.padding(Spacing.lg)
.background(palette.surface, in: shape)
.cardElevation()
.accessibilityIdentifier("account.summary")
}
@@ -378,7 +389,7 @@ struct AccountCenterView: View {
if coordinator.operation == .deletingAccount {
ProgressView("account.delete.loading")
.tint(palette.accent)
.tint(palette.textPrimary)
.frame(maxWidth: .infinity)
}
@@ -393,22 +404,16 @@ struct AccountCenterView: View {
Button("common.cancel") {
showDeleteReauthentication = false
}
.tint(palette.textPrimary)
}
}
}
.toolbarBackground(palette.background, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.presentationDetents([.medium])
.presentationBackground(palette.background)
}
private var operationErrorBinding: Binding<Bool> {
Binding(
get: { coordinator.operationErrorKey != nil },
set: { isPresented in
if !isPresented {
coordinator.dismissOperationError()
}
}
)
}
}
struct AccountAvatarView: View {
@@ -416,28 +421,20 @@ struct AccountAvatarView: View {
let displayName: String?
let size: CGFloat
private static let gradients: [[Color]] = [
[.indigo, .blue],
[.purple, .pink],
[.teal, .cyan],
[.orange, .red],
[.mint, .green]
]
var body: some View {
Text(initials)
.font(.system(size: size * 0.34, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.frame(width: size, height: size)
.background(
LinearGradient(
colors: Self.gradients[gradientIndex],
colors: OSGColor.accountAvatarGradients[gradientIndex],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Circle()
)
.overlay(Circle().stroke(.white.opacity(0.2), lineWidth: 0.5))
.overlay(Circle().stroke(OSGColor.accountAvatarOutline, lineWidth: 0.5))
.accessibilityHidden(true)
}
@@ -453,7 +450,7 @@ struct AccountAvatarView: View {
let hash = accountID.uuidString.unicodeScalars.reduce(UInt32(2_166_136_261)) {
($0 ^ $1.value) &* 16_777_619
}
return Int(hash % UInt32(Self.gradients.count))
return Int(hash % UInt32(OSGColor.accountAvatarGradients.count))
}
}
@@ -578,13 +575,13 @@ private struct AccountCreditPurchaseSection: View {
if purchasingProductID == option.productID {
ProgressView()
.controlSize(.small)
.tint(.white)
.tint(OSGColor.fixedLightContent)
} else {
Text("account.storekit.purchase")
.font(TypeStyle.caption.weight(.semibold))
}
}
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, Spacing.sm)
.frame(minWidth: 54, minHeight: 34)
.background(
@@ -717,22 +714,18 @@ struct AccountCreditProgress: View {
var body: some View {
VStack(spacing: Spacing.xs) {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule()
.fill(palette.surfaceElevated)
HStack(spacing: 0) {
Rectangle()
.fill(palette.success)
.frame(width: proxy.size.width * remainingFraction)
Rectangle()
.fill(palette.textTertiary.opacity(0.55))
.frame(width: proxy.size.width * usedFraction)
}
.clipShape(Capsule())
}
}
.frame(height: 8)
ProgressTrack(
segments: [
ProgressTrackSegment(
fraction: remainingFraction,
color: palette.success
),
ProgressTrackSegment(
fraction: usedFraction,
color: palette.textTertiary.opacity(0.32)
)
]
)
if showsLabels {
HStack(spacing: Spacing.sm) {
@@ -787,7 +780,7 @@ struct AccountInvitationButton: View {
} label: {
Label("account.referral.inviteTitle", systemImage: "person.badge.plus")
.font(TypeStyle.bodyEmph)
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(
@@ -245,14 +245,14 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
let authorizationCode: String
let nonce: String
let displayName: String?
let userIdentifier: String?
let userIdentifier: String
init(
identityToken: String,
authorizationCode: String,
nonce: String,
displayName: String? = nil,
userIdentifier: String? = nil
userIdentifier: String
) {
self.identityToken = identityToken
self.authorizationCode = authorizationCode
@@ -265,6 +265,7 @@ struct AppleAuthorizationPayload: Equatable, Sendable {
enum AccountAppleCredentialState: Equatable, Sendable {
case authorized
case revoked
case reauthenticationRequired
case unknown
}
@@ -68,19 +68,12 @@ struct AccountPurchaseHistoryView: View {
private func purchaseList(
_ records: [AccountCreditPurchaseRecord]
) -> some View {
List {
ScrollView {
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(records) { record in
purchaseRow(record)
.listRowBackground(palette.surface)
.listRowInsets(
EdgeInsets(
top: 0,
leading: Spacing.md,
bottom: 0,
trailing: Spacing.md
)
)
.listRowSeparatorTint(palette.divider)
.padding(.horizontal, Spacing.md)
.surfaceCard()
.task {
guard record.id == records.last?.id else { return }
await manager.loadNextPurchaseHistoryPage(accountID: accountID)
@@ -94,7 +87,8 @@ struct AccountPurchaseHistoryView: View {
.tint(palette.accent)
Spacer()
}
.listRowBackground(palette.surface)
.settingsListRow()
.surfaceCard()
} else if let errorKey = manager.historyLoadMoreErrorKey {
Button {
Task {
@@ -107,11 +101,15 @@ struct AccountPurchaseHistoryView: View {
.frame(maxWidth: .infinity)
}
.buttonStyle(.plain)
.listRowBackground(palette.surface)
.settingsListRow()
.surfaceCard()
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.xxl)
}
.scrollClipDisabled()
.background(palette.background)
.accessibilityIdentifier("account.purchaseHistory.list")
.refreshable {
@@ -31,15 +31,6 @@ struct AccountRewardsCard: View {
guard coordinator.isSignedIn else { return }
await coordinator.refreshAccountData()
}
.alert("account.error.title", isPresented: operationErrorBinding) {
Button("common.done") {
coordinator.dismissOperationError()
}
} message: {
if let key = coordinator.operationErrorKey {
Text(LocalizedStringKey(key))
}
}
}
private var signedOutContent: some View {
@@ -59,10 +50,10 @@ struct AccountRewardsCard: View {
)
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("home.accountRewards.signedOut.title")
Text("account.signedOut.title")
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
Text("home.accountRewards.signedOut.summary")
Text("account.signedOut.body")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
@@ -189,16 +180,6 @@ struct AccountRewardsCard: View {
)
}
private var operationErrorBinding: Binding<Bool> {
Binding(
get: { coordinator.operationErrorKey != nil },
set: { isPresented in
if !isPresented {
coordinator.dismissOperationError()
}
}
)
}
}
private struct AccountReferralLinkView: View {
@@ -58,8 +58,10 @@ final class AccountSessionCoordinator: ObservableObject {
private var didAttemptRestore = false
private var accountRefreshTask: Task<Void, Never>?
private var accountRefreshRequestID: UUID?
private var pendingForcedAccountRefresh = false
private var sessionEventsTask: Task<Void, Never>?
private var appleCredentialRevocationCancellable: AnyCancellable?
private var sessionRevision: UInt64 = 0
init(
dependencies: AccountDependencies,
@@ -70,6 +72,7 @@ final class AccountSessionCoordinator: ObservableObject {
UserDefaultsReferralProfileStore(),
accountRefreshInterval: TimeInterval = 10 * 60,
now: @escaping () -> Date = Date.init,
notificationCenter: NotificationCenter = .default,
analyticsClient: any AnalyticsClient = NoopAnalyticsClient(),
onAccountAuthenticated: @escaping (UUID) async -> Void = { _ in },
onAccountSignedOut: @escaping () async -> Void = {},
@@ -102,11 +105,13 @@ final class AccountSessionCoordinator: ObservableObject {
await self?.handleSessionEvent(event)
}
}
appleCredentialRevocationCancellable = NotificationCenter.default
appleCredentialRevocationCancellable = notificationCenter
.publisher(for: ASAuthorizationAppleIDProvider.credentialRevokedNotification)
.sink { [weak self] _ in
Task { @MainActor [weak self] in
await self?.handleAppleCredentialRevocation()
await self?.handleAppleCredentialRevocation(
errorKey: "account.error.sessionExpired"
)
}
}
}
@@ -142,6 +147,7 @@ final class AccountSessionCoordinator: ObservableObject {
return
}
await onAccountAuthenticated(session.accountID)
advanceSessionRevision()
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
@@ -167,11 +173,19 @@ final class AccountSessionCoordinator: ObservableObject {
}
func validateAppleCredentialState() async {
guard isSignedIn,
await sessionService.appleCredentialState() == .revoked else {
guard isSignedIn else { return }
switch await sessionService.appleCredentialState() {
case .authorized, .unknown:
return
case .revoked:
await handleAppleCredentialRevocation(
errorKey: "account.error.sessionExpired"
)
case .reauthenticationRequired:
await handleAppleCredentialRevocation(
errorKey: "account.error.appleReauthenticationRequired"
)
}
await handleAppleCredentialRevocation()
}
@discardableResult
@@ -208,6 +222,7 @@ final class AccountSessionCoordinator: ObservableObject {
do {
let session = try await sessionService.signIn(with: payload)
await onAccountAuthenticated(session.accountID)
advanceSessionRevision()
sessionPhase = .signedIn(session)
creditPurchases.startSession(accountID: session.accountID)
referralProfile.startSession(accountID: session.accountID)
@@ -229,6 +244,9 @@ final class AccountSessionCoordinator: ObservableObject {
return
}
if let accountRefreshTask {
if force {
pendingForcedAccountRefresh = true
}
await accountRefreshTask.value
return
}
@@ -257,16 +275,27 @@ final class AccountSessionCoordinator: ObservableObject {
accountRefreshTask = task
await task.value
finishAccountRefresh(requestID: requestID)
let shouldRunForcedRefresh = pendingForcedAccountRefresh
pendingForcedAccountRefresh = false
if shouldRunForcedRefresh, self.accountID != nil {
await refreshAccountData(force: true)
}
}
func updateDisplayName(_ displayName: String) async -> Bool {
guard operation == nil, isSignedIn else { return false }
guard operation == nil, let expectedAccountID = accountID else { return false }
let expectedRevision = sessionRevision
operation = .updatingProfile
operationErrorKey = nil
defer { operation = nil }
do {
let account = try await centerService.updateDisplayName(displayName)
guard sessionRevision == expectedRevision,
accountID == expectedAccountID,
account.accountID == expectedAccountID else {
return false
}
sessionPhase = .signedIn(account)
if case let .loaded(snapshot) = snapshotPhase {
snapshotPhase = .loaded(
@@ -279,6 +308,10 @@ final class AccountSessionCoordinator: ObservableObject {
}
return true
} catch {
guard sessionRevision == expectedRevision,
accountID == expectedAccountID else {
return false
}
operationErrorKey = errorMessageKey(
for: error,
fallback: "account.error.updateProfile"
@@ -288,14 +321,24 @@ final class AccountSessionCoordinator: ObservableObject {
}
func prepareManagedGateway() async -> Bool {
guard operation == nil, isSignedIn else { return false }
guard operation == nil, let expectedAccountID = accountID else { return false }
let expectedRevision = sessionRevision
operation = .preparingManagedGateway
operationErrorKey = nil
defer { operation = nil }
do {
try await sessionService.prepareManagedGateway()
guard sessionRevision == expectedRevision,
accountID == expectedAccountID else {
await sessionService.clearManagedGateway()
return false
}
return true
} catch {
guard sessionRevision == expectedRevision,
accountID == expectedAccountID else {
return false
}
operationErrorKey = errorMessageKey(
for: error,
fallback: "account.error.managedGateway"
@@ -318,6 +361,7 @@ final class AccountSessionCoordinator: ObservableObject {
do {
try await sessionService.signOut()
await onAccountSignedOut()
advanceSessionRevision()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
@@ -342,6 +386,7 @@ final class AccountSessionCoordinator: ObservableObject {
try await sessionService.deleteAccount(with: payload)
await onAccountDeleted()
await onAccountSignedOut()
advanceSessionRevision()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession(removeCache: true)
@@ -412,6 +457,7 @@ final class AccountSessionCoordinator: ObservableObject {
accountRefreshTask?.cancel()
accountRefreshTask = nil
accountRefreshRequestID = nil
pendingForcedAccountRefresh = false
lastAccountRefreshAt = nil
isRefreshingAccountData = false
accountRefreshErrorKey = nil
@@ -427,26 +473,48 @@ final class AccountSessionCoordinator: ObservableObject {
private func handleSessionEvent(_ event: AccountSessionEvent) async {
switch event {
case .expired:
await transitionToExpiredSession()
await expireSession(
errorKey: "account.error.sessionExpired",
signsOutService: false
)
}
}
private func handleAppleCredentialRevocation() async {
guard isSignedIn else { return }
try? await sessionService.signOut()
await transitionToExpiredSession()
private func handleAppleCredentialRevocation(errorKey: String) async {
guard operation != .signingOut,
operation != .deletingAccount else {
return
}
await expireSession(errorKey: errorKey, signsOutService: true)
}
private func transitionToExpiredSession() async {
private func expireSession(
errorKey: String,
signsOutService: Bool
) async {
guard isSignedIn else { return }
advanceSessionRevision()
if signsOutService {
do {
try await sessionService.signOut()
} catch {
operationErrorKey = "account.error.signOut"
return
}
}
await sessionService.clearManagedGateway()
await onAccountSignedOut()
creditPurchases.endSession()
clearAccountRefreshState()
referralProfile.endSession()
operation = nil
sessionPhase = .signedOut
snapshotPhase = .idle
operationErrorKey = "account.error.sessionExpired"
operationErrorKey = errorKey
}
private func advanceSessionRevision() {
sessionRevision &+= 1
}
private func redeemPendingReferralIfNeeded() async {
@@ -186,7 +186,7 @@ private struct SystemAppleCredentialStateChecker: AppleCredentialStateChecking {
case .revoked, .notFound:
continuation.resume(returning: .revoked)
case .transferred:
continuation.resume(returning: .unknown)
continuation.resume(returning: .reauthenticationRequired)
@unknown default:
continuation.resume(returning: .unknown)
}
@@ -324,6 +324,15 @@ private actor LiveAccountService:
+ "appAttest=\(evidence.appAttest == nil ? 0 : 1)",
category: "account"
)
do {
try await appleUserIdentifiers.saveAppleUserIdentifier(payload.userIdentifier)
} catch {
OSGDiag.log(
"signIn failed stage=apple-user-identifier",
category: "account"
)
throw AccountAPIError.secureStorage
}
let session: OSGKeyboardHostSupport.AccountSession
do {
session = try await apiClient.signInWithApple(
@@ -337,33 +346,43 @@ private actor LiveAccountService:
)
)
} catch {
try? await appleUserIdentifiers.clearAppleUserIdentifier()
OSGDiag.log(
"signIn failed stage=session error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
}
if let userIdentifier = payload.userIdentifier,
!userIdentifier.isEmpty {
try? await appleUserIdentifiers.saveAppleUserIdentifier(userIdentifier)
}
let account: OSGAccount
let account: OSGAccount?
do {
account = try await apiClient.account()
} catch {
} catch let error as AccountAPIError {
switch error {
case .sessionUnavailable, .unauthorized, .refreshTokenReuse:
try? await appleUserIdentifiers.clearAppleUserIdentifier()
await invalidateManagedGatewaySession()
throw error
default:
OSGDiag.log(
"signIn failed stage=account error=\(AccountDiagnostic.code(for: error))",
"signIn account fallback error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
throw error
account = nil
}
} catch {
OSGDiag.log(
"signIn account fallback error=\(AccountDiagnostic.code(for: error))",
category: "account"
)
account = nil
}
configuration.setManagedGatewayAccountSessionAvailable(true)
await synchronizeManagedGrant()
OSGDiag.log("signIn completed", category: "account")
return uiSession(
session,
createdAtEpochSeconds: account.createdAtEpochSeconds,
displayName: account.displayName
createdAtEpochSeconds: account?.createdAtEpochSeconds ?? 0,
displayName: account?.displayName ?? payload.displayName
)
}
@@ -375,7 +394,6 @@ private actor LiveAccountService:
try await apiClient.logout()
} catch {
try? await grants.clearGrant()
try? await appleUserIdentifiers.clearAppleUserIdentifier()
throw error
}
try? await grants.clearGrant()
@@ -396,10 +414,15 @@ private actor LiveAccountService:
}
func appleCredentialState() async -> AccountAppleCredentialState {
guard let userIdentifier = try? await appleUserIdentifiers.loadAppleUserIdentifier(),
!userIdentifier.isEmpty else {
let userIdentifier: String?
do {
userIdentifier = try await appleUserIdentifiers.loadAppleUserIdentifier()
} catch {
return .unknown
}
guard let userIdentifier, !userIdentifier.isEmpty else {
return .reauthenticationRequired
}
return await appleCredentialChecker.state(for: userIdentifier)
}
@@ -34,7 +34,7 @@ struct ClipboardHistoryDemoView: View {
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
OSGColor.demoBackground.ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
keyboardChrome
@@ -12,9 +12,10 @@
// (backgrounds do not need 120 Hz) roughly halves GPU vs. uncapped TimelineView.
// - `accessibilityReduceMotion` static frame at phase = 0 (Apple HIG).
// - Palette is the same near-black base used across the rest of the app
// (`Color(red: 0.06, green: 0.06, blue: 0.07)`) with cool-warm accents that
// (`OSGColor.demoBackground`) with cool-warm accents that
// sit in the "premium, calm, late-night" register fits the Typeless vibe.
import OSGKeyboardShared
import SwiftUI
@available(iOS 18.0, *)
@@ -108,43 +109,13 @@ struct AnimatedMeshPalette: Sendable {
var colors: [Color]
/// Deep indigo / amber / teal. Default works for the dark home screen.
static let aurora = AnimatedMeshPalette(colors: [
Color(red: 0.06, green: 0.07, blue: 0.13), // top-left
Color(red: 0.10, green: 0.08, blue: 0.20), // top-mid
Color(red: 0.05, green: 0.10, blue: 0.18), // top-right
Color(red: 0.12, green: 0.09, blue: 0.16), // left-mid
Color(red: 0.18, green: 0.11, blue: 0.22), // center deepest violet
Color(red: 0.08, green: 0.12, blue: 0.20), // right-mid
Color(red: 0.16, green: 0.10, blue: 0.10), // bottom-left (amber hint)
Color(red: 0.10, green: 0.08, blue: 0.18), // bottom-mid
Color(red: 0.05, green: 0.09, blue: 0.14) // bottom-right
])
static let aurora = AnimatedMeshPalette(colors: OSGColor.meshAurora)
/// Cooler / more "Apple" feel slate blue, steel, ice.
static let polar = AnimatedMeshPalette(colors: [
Color(red: 0.05, green: 0.07, blue: 0.10),
Color(red: 0.09, green: 0.12, blue: 0.18),
Color(red: 0.07, green: 0.09, blue: 0.14),
Color(red: 0.10, green: 0.13, blue: 0.18),
Color(red: 0.13, green: 0.16, blue: 0.22),
Color(red: 0.08, green: 0.10, blue: 0.16),
Color(red: 0.06, green: 0.10, blue: 0.16),
Color(red: 0.09, green: 0.11, blue: 0.18),
Color(red: 0.05, green: 0.07, blue: 0.12)
])
static let polar = AnimatedMeshPalette(colors: OSGColor.meshPolar)
/// Warm "candlelight" fits evening writing session vibes.
static let ember = AnimatedMeshPalette(colors: [
Color(red: 0.08, green: 0.06, blue: 0.05),
Color(red: 0.14, green: 0.09, blue: 0.06),
Color(red: 0.10, green: 0.07, blue: 0.06),
Color(red: 0.16, green: 0.10, blue: 0.07),
Color(red: 0.22, green: 0.13, blue: 0.08),
Color(red: 0.12, green: 0.08, blue: 0.07),
Color(red: 0.10, green: 0.06, blue: 0.05),
Color(red: 0.15, green: 0.09, blue: 0.06),
Color(red: 0.08, green: 0.06, blue: 0.05)
])
static let ember = AnimatedMeshPalette(colors: OSGColor.meshEmber)
}
// MARK: - Helpers
@@ -26,5 +26,6 @@ struct PrivacyInfoCard: View {
.padding(Spacing.md)
.frame(maxWidth: .infinity, alignment: .leading)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
}
@@ -37,5 +37,6 @@ struct WideCard<Content: View>: View {
content()
.padding(padding)
.background(palette.surface, in: shape)
.cardElevation()
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ struct EditDemoView: View {
var body: some View {
ZStack {
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
OSGColor.demoBackground.ignoresSafeArea()
VStack(spacing: 0) {
Spacer(minLength: 0)
Group {
+1 -1
View File
@@ -75,7 +75,7 @@ struct EnginePickerSection<ConfigurationRows: View>: View {
HStack(spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.body)
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
Text(subtitle)
.font(TypeStyle.caption2)
@@ -0,0 +1,139 @@
// FlowDiagnosticsSettingsView.swift
// OSGKeyboard · Main App
//
// Local-only export and deletion controls for Flow startup failure reports.
import OSGKeyboardShared
import SwiftUI
struct FlowDiagnosticsSettingsView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var config = ProviderConfig.shared
@State private var reportURLs: [URL] = []
@State private var exportURL: URL?
@State private var showsClearConfirmation = false
var body: some View {
ScrollView {
CardPageContent {
CardSection("settings.diagnostics.section") {
VStack(spacing: 0) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(reportCountText)
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(reportStatusText)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.settingsListRow()
if let exportURL {
Divider().background(palette.divider)
ShareLink(item: exportURL) {
actionRow(
title: "settings.diagnostics.export",
systemImage: "square.and.arrow.up"
)
}
.buttonStyle(.plain)
Divider().background(palette.divider)
Button(role: .destructive) {
showsClearConfirmation = true
} label: {
actionRow(
title: "settings.diagnostics.clear",
systemImage: "trash"
)
}
.buttonStyle(.plain)
}
}
.surfaceCard()
}
CardSection("settings.diagnostics.privacy.section") {
Text("settings.diagnostics.privacy.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.surfaceCard()
}
}
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.diagnostics.title")
.navigationBarTitleDisplayMode(.inline)
.onAppear(perform: refreshReports)
.alert("settings.diagnostics.clear.title", isPresented: $showsClearConfirmation) {
Button("common.cancel", role: .cancel) {}
Button("settings.diagnostics.clear.confirm", role: .destructive) {
FlowFailureLogStore.shared.deleteAllReports()
refreshReports()
}
} message: {
Text("settings.diagnostics.clear.message")
}
}
private var reportCountText: String {
String(
format: AppL10n.string(
"settings.diagnostics.count",
language: config.uiLanguage
),
reportURLs.count
)
}
private var reportStatusText: String {
guard let latest = reportURLs.first,
let modifiedAt = try? latest.resourceValues(
forKeys: [.contentModificationDateKey]
).contentModificationDate else {
return AppL10n.string(
"settings.diagnostics.empty",
language: config.uiLanguage
)
}
let format = AppL10n.string(
"settings.diagnostics.latest",
language: config.uiLanguage
)
return String(
format: format,
modifiedAt.formatted(date: .abbreviated, time: .shortened)
)
}
private func actionRow(
title: LocalizedStringKey,
systemImage: String
) -> some View {
HStack(spacing: Spacing.sm) {
Image(systemName: systemImage)
.foregroundStyle(palette.accent)
.frame(width: 22)
Text(title)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.sm)
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.textTertiary)
}
.settingsListRow()
.contentShape(Rectangle())
}
private func refreshReports() {
reportURLs = FlowFailureLogStore.shared.reportURLs()
exportURL = FlowFailureLogStore.shared.makeExportURL()
}
}
+28 -30
View File
@@ -47,10 +47,8 @@ struct HistoryView: View {
} label: {
Image(systemName: "trash")
}
.tint(palette.textPrimary)
.accessibilityLabel("history.clear.button")
}
}
}
.confirmationDialog(
"history.clear.title",
isPresented: $showClearConfirmation,
@@ -63,6 +61,9 @@ struct HistoryView: View {
} message: {
Text("history.clear.message")
}
}
}
}
.confirmationDialog(
"history.clearDay.title",
isPresented: $showDeleteDayConfirmation,
@@ -82,37 +83,37 @@ struct HistoryView: View {
}
}
// MARK: - List
// MARK: - Card list
private var list: some View {
List {
ScrollView {
CardPageContent {
ForEach(store.groupedByDay, id: \.day) { group in
Section {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
daySectionHeader(day: group.day)
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(group.items) { entry in
historyRow(entry)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(palette.surface)
.listRowSeparatorTint(palette.divider)
}
.onDelete { offsets in
delete(items: group.items, at: offsets)
}
} header: {
daySectionHeader(day: group.day)
}
.listSectionMargins(.horizontal, Spacing.lg)
.surfaceCard()
.contextMenu {
Button("common.delete", role: .destructive) {
delete(entry)
}
}
.listStyle(.insetGrouped)
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden)
}
}
}
}
}
.tabBarScrollBottomPadding()
}
.scrollClipDisabled()
.background(palette.background)
.contentMargins(.top, Spacing.md, for: .scrollContent)
.tabBarListScrollBottomMargin()
}
/// Date label + per-day delete, flush with the section card's left/right edges
/// (Settings section labels share the same edge; system List headers inset further).
/// (CardPageContent gives labels and cards the same horizontal guide).
private func daySectionHeader(day: Date) -> some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(Self.dayFormatter.string(from: day))
@@ -134,9 +135,8 @@ struct HistoryView: View {
.accessibilityLabel("history.clearDay.button")
}
.frame(maxWidth: .infinity, alignment: .leading)
// Cancel the default List section-header content inset so the label
// lines up with the card's left edge (rows use leading: 0).
.padding(.horizontal, -SettingsListMetrics.rowHorizontalPadding)
// CardPageContent owns horizontal padding, so no row-specific
// compensation is needed to keep the label flush with the cards.
.textCase(nil)
}
@@ -181,9 +181,7 @@ struct HistoryView: View {
// MARK: - Mutations
private func delete(items: [SpeechHistoryEntry], at offsets: IndexSet) {
for index in offsets {
store.delete(id: items[index].id)
}
private func delete(_ entry: SpeechHistoryEntry) {
store.delete(id: entry.id)
}
}
+33 -6
View File
@@ -352,6 +352,23 @@ struct HomeView: View {
compact: Bool = false
) -> some View {
VStack(spacing: CardLayoutMetrics.sectionSpacing) {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--home-dictionary-screenshot") {
dictionaryCard
} else {
standardHomeContentSections(layout: layout, compact: compact)
}
#else
standardHomeContentSections(layout: layout, compact: compact)
#endif
}
}
@ViewBuilder
private func standardHomeContentSections(
layout: UsageStatsClusterLayout,
compact: Bool
) -> some View {
AccountRewardsCard {
path.append(HomeRoute.account)
}
@@ -376,7 +393,6 @@ struct HomeView: View {
dictionaryCard
}
}
private var dictionaryCard: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
@@ -496,14 +512,18 @@ struct HomeView: View {
pendingDictionarySuggestion = nil
}
.buttonStyle(.bordered)
.tint(palette.textPrimary)
Spacer(minLength: 0)
Button("home.card.dictionary.confirm.add") {
Button {
addSuggestedTerm(suggestion)
} label: {
Text("home.card.dictionary.confirm.add")
.foregroundStyle(palette.background)
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.tint(palette.textPrimary)
}
}
.padding(Spacing.lg)
@@ -576,9 +596,11 @@ struct HomeView: View {
private var headerGradientColors: [Color] {
// API Key
if sessionIsLive, !needsAPIKeySetup {
let leadingOpacity = colorScheme == .dark ? 0.18 : 0.28
let trailingOpacity = colorScheme == .dark ? 0.06 : 0.10
return [
palette.accent.opacity(0.28),
palette.accent.opacity(0.10),
palette.accent.opacity(leadingOpacity),
palette.accent.opacity(trailingOpacity),
palette.background.opacity(0)
]
}
@@ -601,7 +623,11 @@ struct HomeView: View {
.renderingMode(.template)
.scaledToFit()
.frame(width: logoWidth, height: logoHeight)
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
.foregroundStyle(
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity)
@@ -769,6 +795,7 @@ struct HomeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.cardElevation()
}
private var flowStatusColor: Color {
+2 -1
View File
@@ -96,7 +96,8 @@ struct KeyboardPreviewSheet: View {
.padding(.horizontal, 2)
}
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.background(palette.formSurface, in: RoundedRectangle(cornerRadius: Radius.medium))
.cardElevation()
}
private var controls: some View {
+1 -1
View File
@@ -217,7 +217,7 @@ struct KeyboardPreviewStub: View {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
case .recording:
HStack(spacing: 3) {
ForEach(0..<12, id: \.self) { i in
+28 -4
View File
@@ -19,6 +19,7 @@ enum AnalyticsFirstOpenAttribution {
struct MainAppRoot: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.themePalette) private var palette: ThemePalette
// Singleton is owned by `ProviderConfig.shared`, not by this view
// `@ObservedObject` keeps subscriptions correct across Settings replay.
@@ -64,13 +65,27 @@ struct MainAppRoot: View {
}
var body: some View {
Group {
ZStack {
palette.background
.ignoresSafeArea()
mainContent
}
.environment(\.locale, config.uiLanguage.swiftUILocale)
.environmentObject(flowManager)
.environmentObject(accountSession)
.environmentObject(analytics)
.alert(
"account.error.title",
isPresented: accountOperationErrorBinding
) {
Button("common.done") {
accountSession.dismissOperationError()
}
} message: {
if let key = accountSession.operationErrorKey {
Text(LocalizedStringKey(key))
}
}
.background {
FlowPiPHostView { view in
flowManager.attachPiPHostView(view)
@@ -209,6 +224,7 @@ struct MainAppRoot: View {
// launch. The coordinator no-ops after a terminal restore, so
// this only retries a previously interrupted attempt.
await accountSession.restoreIfNeeded()
await accountSession.validateAppleCredentialState()
}
if config.hasCompletedOnboarding {
activateForegroundServices(reason: "scenePhase.active")
@@ -217,11 +233,19 @@ struct MainAppRoot: View {
releaseNotes.presentIfNeeded(onboardingCompleted: true)
}
}
Task {
await accountSession.validateAppleCredentialState()
await AppCloudSync.shared.pullAllIfEnabled()
Task { await AppCloudSync.shared.pullAllIfEnabled() }
}
}
private var accountOperationErrorBinding: Binding<Bool> {
Binding(
get: { accountSession.operationErrorKey != nil },
set: { isPresented in
if !isPresented {
accountSession.dismissOperationError()
}
}
)
}
private func refreshOfficialSkillCatalog(reason: String) {
+351 -51
View File
@@ -67,6 +67,19 @@ private extension ManagedGatewayOOBEFeature {
}
}
var editorSystemImage: String {
switch self {
case .voiceInput:
return "message"
case .clipboardTranslate:
return "translate"
case .clipboardReply:
return "arrowshape.turn.up.left"
case .askAI:
return "sparkles.2"
}
}
var next: ManagedGatewayOOBEFeature? {
switch self {
case .voiceInput:
@@ -81,9 +94,18 @@ private extension ManagedGatewayOOBEFeature {
}
}
#if DEBUG
private enum OOBEPreviewDestination {
case practice(ManagedGatewayOOBEFeature)
case loginReward
case complete
}
#endif
struct OnboardingExperienceView: View {
@Environment(\.scenePhase) private var scenePhase
@Environment(\.themePalette) private var palette
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@EnvironmentObject private var flowManager: FlowSessionManager
@EnvironmentObject private var accountSession: AccountSessionCoordinator
@@ -104,11 +126,15 @@ struct OnboardingExperienceView: View {
@State private var isPreparingManagedPractice = false
@State private var managedPracticeReady = false
@State private var managedPracticeFailed = false
@State private var practiceFeature: ManagedGatewayOOBEFeature = .voiceInput
@State private var practiceFeature = Self.initialPracticeFeature
@State private var practiceSessionID: UUID?
@State private var completedPracticeFeatures: Set<ManagedGatewayOOBEFeature> = []
@State private var didCopyPracticeSample = false
@State private var didRefreshLoginReward = false
@State private var showsKeyboardSwitchIcon = false
@State private var showsLoginRewardIcon = false
@State private var showsCompleteIcon = false
@State private var previewPageIndex = 0
@FocusState private var keyboardSwitchFieldFocused: Bool
@FocusState private var practiceFieldFocused: Bool
@@ -117,8 +143,121 @@ struct OnboardingExperienceView: View {
"onboarding.experience.oobe.completedFeatures.v1"
private var currentStep: OnboardingExperienceStep {
OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction
#if DEBUG
switch activePreviewDestination {
case .practice:
return .practice
case .loginReward:
return .loginReward
case .complete:
return .complete
case nil:
break
}
#endif
return OnboardingExperienceStep(rawValue: config.onboardingPage) ?? .introduction
}
#if DEBUG
private var activePreviewDestination: OOBEPreviewDestination? {
guard Self.previewsAllScreensFromArguments else {
return Self.previewDestination
}
return Self.allPreviewDestinations[
min(previewPageIndex, Self.allPreviewDestinations.count - 1)
]
}
#endif
private var isPreviewMode: Bool {
#if DEBUG
Self.isPreviewEnabled
#else
false
#endif
}
private var previewLanguageOverride: AppUILanguage? {
#if DEBUG
isPreviewMode ? Self.previewLanguage : nil
#else
nil
#endif
}
private var previewsAllScreens: Bool {
#if DEBUG
Self.previewsAllScreensFromArguments
#else
false
#endif
}
private static var initialPracticeFeature: ManagedGatewayOOBEFeature {
#if DEBUG
if case let .practice(feature) = previewDestination {
return feature
}
#endif
return .voiceInput
}
#if DEBUG
static var isPreviewEnabled: Bool {
previewDestination != nil
}
static var previewLanguage: AppUILanguage {
let arguments = ProcessInfo.processInfo.arguments
if arguments.contains("--screenshot-lang=en") {
return .english
}
if arguments.contains("--screenshot-lang=zh") {
return .chinese
}
return ProviderConfig.shared.uiLanguage
}
private static var previewsAllScreensFromArguments: Bool {
ProcessInfo.processInfo.arguments.contains("--oobe-preview=all")
}
private static let allPreviewDestinations: [OOBEPreviewDestination] = [
.practice(.voiceInput),
.practice(.clipboardTranslate),
.practice(.clipboardReply),
.practice(.askAI),
.loginReward,
.complete
]
private static var previewDestination: OOBEPreviewDestination? {
let prefix = "--oobe-preview="
guard let argument = ProcessInfo.processInfo.arguments.first(
where: { $0.hasPrefix(prefix) }
) else {
return nil
}
switch String(argument.dropFirst(prefix.count)) {
case "all":
return .practice(.voiceInput)
case "voice":
return .practice(.voiceInput)
case "translate":
return .practice(.clipboardTranslate)
case "reply":
return .practice(.clipboardReply)
case "ask-ai":
return .practice(.askAI)
case "login":
return .loginReward
case "complete":
return .complete
default:
return nil
}
}
#endif
var body: some View {
ZStack {
@@ -137,6 +276,7 @@ struct OnboardingExperienceView: View {
}
}
.onAppear {
guard !isPreviewMode else { return }
migrateLegacyProgressIfNeeded()
applyPrivacySafeDefaultsIfNeeded()
refreshState()
@@ -147,9 +287,11 @@ struct OnboardingExperienceView: View {
}
}
.onDisappear {
guard !isPreviewMode else { return }
endPractice()
}
.onChange(of: scenePhase) { _, phase in
guard !isPreviewMode else { return }
guard phase == .active else { return }
refreshState()
if currentStep == .keyboard, hasOpenedKeyboardSettings {
@@ -164,6 +306,7 @@ struct OnboardingExperienceView: View {
}
}
.onChange(of: currentStep) { previous, current in
guard !isPreviewMode else { return }
if previous == .keyboardSwitch {
keyboardSwitchFieldFocused = false
}
@@ -182,15 +325,18 @@ struct OnboardingExperienceView: View {
}
}
.onChange(of: accountSession.isSignedIn) { _, isSignedIn in
guard !isPreviewMode else { return }
guard isSignedIn, currentStep == .loginReward else { return }
didRefreshLoginReward = false
refreshLoginRewardIfNeeded()
}
.onChange(of: config.hasAcknowledgedCloudSharing) { _, acknowledged in
guard !isPreviewMode else { return }
guard acknowledged, currentStep == .practice else { return }
prepareManagedPractice()
}
.task(id: currentStep) {
guard !isPreviewMode else { return }
switch currentStep {
case .keyboardSwitch:
await monitorKeyboardVerification()
@@ -248,17 +394,23 @@ struct OnboardingExperienceView: View {
Image("osglogo")
.resizable()
.renderingMode(.template)
.scaledToFit()
.frame(maxWidth: 174, maxHeight: 50)
.frame(maxWidth: 122, maxHeight: 35)
.foregroundStyle(palette.textPrimary)
.accessibilityHidden(true)
Text("onboarding.experience.intro.eyebrow")
.font(TypeStyle.caption)
.font(.system(size: 10, weight: .medium))
.tracking(1.2)
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.sm)
.frame(height: 28)
.background(palette.accentMuted, in: Capsule())
.background(palette.surfaceElevated, in: Capsule())
.overlay {
Capsule()
.stroke(palette.dividerStrong, lineWidth: 0.5)
}
.padding(.top, Spacing.hero)
Text("onboarding.experience.intro.title")
@@ -275,18 +427,18 @@ struct OnboardingExperienceView: View {
VStack(spacing: Spacing.sm) {
promiseRow(
icon: "waveform",
title: "onboarding.experience.privacy.local.title",
detail: "onboarding.experience.privacy.local.body"
title: "onboarding.experience.promise.voice.title",
detail: "onboarding.experience.promise.voice.body"
)
promiseRow(
icon: "network",
title: "onboarding.experience.privacy.cloud.title",
detail: "onboarding.experience.privacy.cloud.body"
icon: "sparkles",
title: "onboarding.experience.promise.agent.title",
detail: "onboarding.experience.promise.agent.body"
)
promiseRow(
icon: "keyboard",
title: "onboarding.experience.privacy.anywhere.title",
detail: "onboarding.experience.privacy.anywhere.body"
icon: "key",
title: "onboarding.experience.promise.privacy.title",
detail: "onboarding.experience.promise.privacy.body"
)
}
.padding(.top, Spacing.xxl)
@@ -316,9 +468,9 @@ struct OnboardingExperienceView: View {
HStack(alignment: .top, spacing: Spacing.md) {
Image(systemName: icon)
.font(.system(size: 19, weight: .semibold))
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
.frame(width: 42, height: 42)
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
VStack(alignment: .leading, spacing: 3) {
Text(title)
@@ -334,6 +486,7 @@ struct OnboardingExperienceView: View {
}
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
// MARK: - Permissions
@@ -390,9 +543,9 @@ struct OnboardingExperienceView: View {
HStack(spacing: Spacing.md) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
.frame(width: 42, height: 42)
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.medium))
VStack(alignment: .leading, spacing: 2) {
Text(title)
@@ -412,6 +565,7 @@ struct OnboardingExperienceView: View {
}
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
// MARK: - Keyboard setup
@@ -465,8 +619,13 @@ struct OnboardingExperienceView: View {
) {
VStack(spacing: Spacing.md) {
Image(systemName: "globe")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(palette.accent)
.font(.system(size: 40, weight: .ultraLight))
.foregroundStyle(palette.textPrimary)
.symbolEffect(
.drawOn,
isActive: !reduceMotion && !showsKeyboardSwitchIcon
)
.symbolEffectsRemoved(reduceMotion)
.accessibilityHidden(true)
Text("onboarding.experience.keyboardSwitch.instruction")
@@ -481,6 +640,7 @@ struct OnboardingExperienceView: View {
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.cardElevation()
.padding(.top, Spacing.xxl)
TextField(
@@ -497,16 +657,9 @@ struct OnboardingExperienceView: View {
.padding(Spacing.md)
.frame(maxWidth: .infinity, minHeight: 72, alignment: .topLeading)
.background(
palette.surface,
palette.formSurface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(
keyboardReady ? palette.accent : palette.dividerStrong,
lineWidth: keyboardReady ? 1.5 : 0.5
)
)
.accessibilityIdentifier("onboarding.keyboardSwitch.textField")
.padding(.top, Spacing.lg)
@@ -537,6 +690,20 @@ struct OnboardingExperienceView: View {
.padding(.top, Spacing.sm)
}
}
.task {
guard !reduceMotion else { return }
showsKeyboardSwitchIcon = false
do {
//
try await Task.sleep(for: .milliseconds(120))
showsKeyboardSwitchIcon = true
} catch {
showsKeyboardSwitchIcon = false
}
}
.onDisappear {
showsKeyboardSwitchIcon = false
}
}
private var keyboardVerificationStatus: LocalizedStringKey {
@@ -634,11 +801,13 @@ struct OnboardingExperienceView: View {
@ViewBuilder
private var practiceContent: some View {
if !config.hasAcknowledgedCloudSharing {
if isPreviewMode {
practiceFeatureContent
} else if !config.hasAcknowledgedCloudSharing {
VStack(spacing: Spacing.md) {
Image(systemName: "sparkles")
.font(.system(size: 34, weight: .semibold))
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
Text("onboarding.experience.practice.cloudBody")
.font(TypeStyle.caption)
@@ -687,16 +856,23 @@ struct OnboardingExperienceView: View {
}
.practiceSetupCard(palette: palette)
} else {
practiceFeatureContent
}
}
private var practiceFeatureContent: some View {
VStack(spacing: Spacing.md) {
if practiceFeature == .voiceInput {
practiceVoiceSample
} else if practiceFeature == .clipboardTranslate
|| practiceFeature == .clipboardReply {
practiceClipboardSample
} else if practiceFeature == .askAI {
practiceAskAIPrompt
}
practiceEditor
HStack(spacing: Spacing.xs) {
if managedPracticeReady {
if managedPracticeReady || isPreviewMode {
ProgressView()
.controlSize(.small)
.tint(palette.accent)
@@ -707,13 +883,13 @@ struct OnboardingExperienceView: View {
}
Button("onboarding.experience.practice.skip") {
guard !isPreviewMode else { return }
goToLoginReward()
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
}
}
@ViewBuilder
private var accountOperationError: some View {
@@ -728,8 +904,10 @@ struct OnboardingExperienceView: View {
private var practiceEditor: some View {
VStack(spacing: 0) {
HStack(spacing: Spacing.xs) {
Image(systemName: practiceFeature == .askAI ? "sparkles" : "message.fill")
.foregroundStyle(palette.accent)
Image(systemName: practiceFeature.editorSystemImage)
.font(.system(size: 16, weight: .regular))
.foregroundStyle(palette.textPrimary)
.accessibilityHidden(true)
Text(practiceFeature.progressKey)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
@@ -749,7 +927,8 @@ struct OnboardingExperienceView: View {
.accessibilityIdentifier("onboarding.practice.textEditor")
}
.frame(maxWidth: .infinity, minHeight: 172, maxHeight: 240)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.formSurface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
private var practiceVoiceSample: some View {
@@ -771,7 +950,7 @@ struct OnboardingExperienceView: View {
private var practiceClipboardSample: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Text("onboarding.experience.practice.sample")
Text(LocalizedStringKey(practiceClipboardSampleLocalizationKey))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
@@ -806,9 +985,29 @@ struct OnboardingExperienceView: View {
}
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
private var practiceAskAIPrompt: some View {
Text("onboarding.experience.practice.askAIPrompt")
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.accentMuted, in: RoundedRectangle(cornerRadius: Radius.large))
}
private var practiceClipboardSampleLocalizationKey: String {
practiceFeature == .clipboardTranslate
? "onboarding.experience.practice.translateSample"
: "onboarding.experience.practice.replySample"
}
private var practiceTitle: LocalizedStringKey {
if isPreviewMode {
return practiceFeature.titleKey
}
if !permissionsReady {
return "onboarding.experience.practice.permissionsTitle"
}
@@ -828,6 +1027,9 @@ struct OnboardingExperienceView: View {
}
private var practiceSubtitle: LocalizedStringKey {
if isPreviewMode {
return practiceFeature.subtitleKey
}
if !permissionsReady {
return "onboarding.experience.practice.permissionsSubtitle"
}
@@ -855,9 +1057,14 @@ struct OnboardingExperienceView: View {
onBack: goBack
) {
VStack(spacing: Spacing.lg) {
Image(systemName: accountSession.isSignedIn ? "checkmark.seal.fill" : "gift.fill")
.font(.system(size: 42, weight: .semibold))
.foregroundStyle(palette.accent)
Image(systemName: "gift.circle")
.font(.system(size: 50, weight: .ultraLight))
.foregroundStyle(palette.textPrimary)
.symbolEffect(
.drawOn,
isActive: !reduceMotion && !showsLoginRewardIcon
)
.symbolEffectsRemoved(reduceMotion)
.accessibilityHidden(true)
Text(
@@ -907,9 +1114,24 @@ struct OnboardingExperienceView: View {
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
)
.cardElevation()
.padding(.top, Spacing.xxl)
.accessibilityIdentifier("onboarding.loginReward.card")
}
.task {
guard !reduceMotion else { return }
showsLoginRewardIcon = false
do {
//
try await Task.sleep(for: .milliseconds(120))
showsLoginRewardIcon = true
} catch {
showsLoginRewardIcon = false
}
}
.onDisappear {
showsLoginRewardIcon = false
}
}
private var loginRewardBalance: Int64? {
@@ -926,14 +1148,15 @@ struct OnboardingExperienceView: View {
VStack(spacing: 0) {
Spacer(minLength: Spacing.xxl)
ZStack {
Circle()
.fill(palette.accentMuted)
Image(systemName: "checkmark.circle")
.font(.system(size: 72, weight: .ultraLight))
.foregroundStyle(palette.textPrimary)
.symbolEffect(
.drawOn,
isActive: !reduceMotion && !showsCompleteIcon
)
.symbolEffectsRemoved(reduceMotion)
.frame(width: 108, height: 108)
Image(systemName: completedAllPracticeFeatures ? "checkmark" : "sparkles")
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(palette.accent)
}
Text(
completedAllPracticeFeatures
@@ -970,20 +1193,35 @@ struct OnboardingExperienceView: View {
.frame(maxWidth: 560)
.frame(maxWidth: .infinity)
}
.task {
guard !reduceMotion else { return }
showsCompleteIcon = false
do {
//
try await Task.sleep(for: .milliseconds(120))
showsCompleteIcon = true
} catch {
showsCompleteIcon = false
}
}
.onDisappear {
showsCompleteIcon = false
}
}
private func capability(_ icon: String, _ title: LocalizedStringKey) -> some View {
VStack(spacing: Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 20, weight: .semibold))
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(palette.accent)
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.frame(maxWidth: .infinity, maxHeight: .infinity)
.aspectRatio(1.42, contentMode: .fit)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large))
.cardElevation()
}
// MARK: - Bottom action
@@ -1005,6 +1243,9 @@ struct OnboardingExperienceView: View {
}
private var primaryActionDisabled: Bool {
if previewsAllScreens {
return false
}
if isRequestingPermissions || (currentStep == .keyboardSwitch && !keyboardReady) {
return true
}
@@ -1048,6 +1289,9 @@ struct OnboardingExperienceView: View {
}
private func performPrimaryAction() {
if advancePreviewIfNeeded() {
return
}
switch currentStep {
case .introduction:
goForward()
@@ -1072,6 +1316,26 @@ struct OnboardingExperienceView: View {
}
}
private func advancePreviewIfNeeded() -> Bool {
#if DEBUG
guard previewsAllScreens else { return false }
guard previewPageIndex < Self.allPreviewDestinations.count - 1 else {
return true
}
withAnimation(Motion.soft) {
previewPageIndex += 1
if case let .practice(feature) = activePreviewDestination {
practiceFeature = feature
practiceText = ""
didCopyPracticeSample = false
}
}
return true
#else
return false
#endif
}
// MARK: - State and actions
private var permissionsReady: Bool {
@@ -1269,8 +1533,16 @@ struct OnboardingExperienceView: View {
}
private func copyPracticeSample() {
let sample = AppL10n.string(
practiceClipboardSampleLocalizationKey,
language: previewLanguageOverride
)
if isPreviewMode {
UIPasteboard.general.string = sample
didCopyPracticeSample = true
return
}
guard let sessionID = practiceSessionID else { return }
let sample = AppL10n.string("onboarding.experience.practice.sample")
guard KeyboardSetupBridge.seedOOBEClipboardMaterial(
sample,
sessionID: sessionID
@@ -1451,6 +1723,32 @@ struct OnboardingExperienceView: View {
}
}
#if DEBUG
@MainActor
struct OOBEPreviewHarness: View {
@ObservedObject private var config = ProviderConfig.shared
@StateObject private var flowManager = FlowSessionManager()
@StateObject private var accountSession: AccountSessionCoordinator
init() {
_accountSession = StateObject(
wrappedValue: AccountSessionCoordinator(
dependencies: LiveAccountDependencyFactory.make()
)
)
}
var body: some View {
ThemedRoot {
OnboardingExperienceView(config: config)
.environmentObject(flowManager)
.environmentObject(accountSession)
}
.environment(\.locale, OnboardingExperienceView.previewLanguage.swiftUILocale)
}
}
#endif
private extension View {
func practiceSetupCard(palette: ThemePalette) -> some View {
frame(maxWidth: .infinity, minHeight: 172)
@@ -1459,6 +1757,7 @@ private extension View {
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.cardElevation()
}
}
@@ -1556,5 +1855,6 @@ private struct KeyboardSettingsPreview: View {
.padding(Spacing.md)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.cardElevation()
}
}
@@ -26,15 +26,14 @@ struct PersonalDictionaryEntrySheet: View {
TextField("settings.personalDictionary.add.field", text: $term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.tint(palette.textPrimary)
.focused($termFocused)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.settingsListRow()
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(termFocused ? palette.accent : palette.divider, lineWidth: termFocused ? 1 : 0.5)
.background(
palette.formSurface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
Text("settings.personalDictionary.add.footer")
@@ -56,11 +55,12 @@ struct PersonalDictionaryEntrySheet: View {
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
.tint(palette.textPrimary)
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") { save() }
.disabled(trimmedTerm.isEmpty)
.tint(palette.accent)
.tint(palette.textPrimary)
}
}
.onAppear {
+22 -22
View File
@@ -45,6 +45,7 @@ struct PersonalDictionaryView: View {
} label: {
Image(systemName: "trash")
}
.tint(palette.textPrimary)
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
.confirmationDialog(
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
@@ -67,6 +68,7 @@ struct PersonalDictionaryView: View {
} label: {
Image(systemName: "plus")
}
.tint(palette.textPrimary)
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
}
}
@@ -94,36 +96,40 @@ struct PersonalDictionaryView: View {
}
}
// MARK: - List
// MARK: - Card list
private var list: some View {
List {
ForEach(filteredSections, id: \.0) { _, items in
Section {
ScrollView {
CardPageContent(topPadding: Spacing.lg) {
ForEach(filteredSections, id: \.0) { category, items in
CardSection(
title: SharedL10n.string(category.labelKey, language: config.uiLanguage)
) {
LazyVStack(spacing: CardLayoutMetrics.compactItemSpacing) {
ForEach(items) { entry in
entryRow(entry)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
.listRowBackground(palette.surface)
.listRowSeparatorTint(palette.divider)
}
.onDelete { offsets in
delete(items: items, at: offsets)
.surfaceCard()
.contextMenu {
Button("common.delete", role: .destructive) {
delete(entry)
}
}
}
}
.listStyle(.insetGrouped)
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden)
}
}
}
.tabBarScrollBottomPadding()
}
.scrollClipDisabled()
.background(palette.background)
//
.contentMargins(.top, Spacing.lg, for: .scrollContent)
// CardPageContent keeps the search results and their section labels
// on the same horizontal guide while preserving top breathing room.
.searchable(
text: $searchText,
placement: .navigationBarDrawer(displayMode: .always),
prompt: "settings.personalDictionary.search.prompt"
)
.tabBarListScrollBottomMargin()
}
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
@@ -254,12 +260,6 @@ struct PersonalDictionaryView: View {
persist()
}
private func delete(items: [PersonalDictionary.Entry], at offsets: IndexSet) {
for index in offsets {
delete(items[index])
}
}
private func clearAll() {
dictionary.recordClearAll()
generatingAliasEntryIDs = []
+63 -16
View File
@@ -10,6 +10,7 @@ import SwiftUI
@MainActor
struct PolishStylesView: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var history = SpeechHistoryStore.shared
@@ -51,6 +52,7 @@ struct PolishStylesView: View {
}
.tabBarScrollBottomPadding()
}
.scrollClipDisabled()
.background(palette.background)
.navigationTitle("polishStyles.title")
.navigationBarTitleDisplayMode(.large)
@@ -61,6 +63,7 @@ struct PolishStylesView: View {
} label: {
Image(systemName: "plus")
}
.tint(palette.textPrimary)
.disabled(catalog.entries.count >= PolishStyleLimits.maximumUserPacks)
.accessibilityLabel(Text("polishStyles.add"))
}
@@ -111,6 +114,15 @@ struct PolishStylesView: View {
let reachedLimit = catalog.entries.count >= PolishStyleLimits.maximumUserPacks
let isActionAvailable = corpus.isReady && !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) {
@@ -134,20 +146,19 @@ struct PolishStylesView: View {
}
}
ProgressView(
value: Double(min(corpus.effectiveCharacterCount, required)),
total: Double(required)
ProgressTrack(
segments: [
ProgressTrackSegment(
fraction: learnedFraction,
color: palette.accent
)
.tint(palette.accent)
]
)
.accessibilityLabel(Text("polishStyles.learn.title"))
.accessibilityValue(Text(progressDescription))
HStack {
Text(
AppL10n.format(
"polishStyles.learn.progress",
Int64(corpus.effectiveCharacterCount),
Int64(required)
)
)
Text(progressDescription)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
@@ -224,6 +235,19 @@ struct PolishStylesView: View {
private func packCard(_ pack: PolishStylePack) -> some View {
let isSelected = pack.id == activeID
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
let selectedFillColors = colorScheme == .dark
? [
OSGColor.selectedCardFillLeadingDark,
OSGColor.selectedCardFillTrailingDark
]
: [
OSGColor.selectedCardFillLeadingLight,
OSGColor.selectedCardFillTrailingLight
]
let selectedStroke = colorScheme == .dark
? OSGColor.selectedCardStrokeDark
: OSGColor.selectedCardStrokeLight
return ZStack(alignment: .topTrailing) {
Button {
activate(pack)
@@ -263,11 +287,25 @@ struct PolishStylesView: View {
CatalogCardChrome.checkIcon(palette: palette)
}
}
.background(
isSelected ? palette.accentMuted : palette.surface,
in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.background {
shape
.fill(palette.surface)
.overlay {
if isSelected {
shape
.fill(
LinearGradient(
colors: selectedFillColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
)
.overlay(shape.stroke(selectedStroke, lineWidth: 0.75))
}
}
}
.clipShape(shape)
.cardElevation(accented: isSelected)
.contextMenu {
Button("polishStyles.duplicate") {
duplicate(pack)
@@ -423,7 +461,7 @@ private struct PolishStylePromptDetailSheet: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.surfaceCard()
.surfaceCard(elevated: false)
}
}
.background(palette.background)
@@ -432,6 +470,7 @@ private struct PolishStylePromptDetailSheet: View {
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
.tint(palette.textPrimary)
}
}
}
@@ -467,9 +506,13 @@ private struct PolishStyleEditorSheet: View {
Form {
Section("polishStyles.editor.name") {
TextField("polishStyles.editor.namePlaceholder", text: $name)
.settingsListRow()
.cardListRow(elevated: false)
}
Section {
Toggle("polishStyles.editor.allowsAddedEmoji", isOn: $allowsAddedEmoji)
.settingsListRow()
.cardListRow(elevated: false)
} footer: {
Text("polishStyles.editor.allowsAddedEmoji.hint")
}
@@ -477,6 +520,8 @@ private struct PolishStyleEditorSheet: View {
TextEditor(text: $prompt)
.font(.body.monospaced())
.frame(minHeight: 320)
.padding(Spacing.md)
.cardListRow(elevated: false)
.onChange(of: prompt) { _, newValue in
// Paste-only custom prompts that declare emoji opt-in
// should flip the toggle so post-processing keeps them.
@@ -505,6 +550,7 @@ private struct PolishStyleEditorSheet: View {
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
.tint(palette.textPrimary)
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") {
@@ -525,6 +571,7 @@ private struct PolishStyleEditorSheet: View {
|| prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| prompt.count > PolishStyleLimits.maximumPromptCharacters
)
.tint(palette.textPrimary)
}
}
}
@@ -0,0 +1,86 @@
// ReleaseNotesScreenshotHarness.swift
// OSGKeyboard · Main App
//
// Deterministic DEBUG hosts for capturing real production cards used by the
// remote What's New page. These hosts seed only simulator-local fixture data.
#if DEBUG
import OSGKeyboardShared
import SwiftUI
@MainActor
struct PolishStylesScreenshotHarness: View {
private let language: AppUILanguage
init() {
language = ReleaseNotesScreenshotFixture.language
ProviderConfig.shared.uiLanguage = language
ReleaseNotesScreenshotFixture.seedReadyStyleCorpus(language: language)
}
var body: some View {
ThemedRoot {
PolishStylesView()
}
.environment(\.locale, language.swiftUILocale)
.preferredColorScheme(.light)
}
}
@MainActor
struct HomeDictionaryScreenshotHarness: View {
@StateObject private var flowManager = FlowSessionManager()
private let language: AppUILanguage
init() {
language = ReleaseNotesScreenshotFixture.language
ProviderConfig.shared.uiLanguage = language
ReleaseNotesScreenshotFixture.seedFrequentTerms(language: language)
}
var body: some View {
ThemedRoot {
HomeView()
.environmentObject(flowManager)
}
.environment(\.locale, language.swiftUILocale)
.preferredColorScheme(.light)
}
}
@MainActor
private enum ReleaseNotesScreenshotFixture {
static var language: AppUILanguage {
ProcessInfo.processInfo.arguments.contains("--screenshot-lang=en")
? .english
: .chinese
}
static func seedReadyStyleCorpus(language: AppUILanguage) {
let history = SpeechHistoryStore.shared
history.clearAll()
let token = language == .chinese ? "自然表达习惯" : "NaturalVoice"
let sample = String(String(repeating: token, count: 500).prefix(2_500))
history.append(
text: sample,
prePolishText: sample,
engineMode: "local",
source: .dictation
)
history.reloadFromDisk()
}
static func seedFrequentTerms(language: AppUILanguage) {
let store = FrequentTermStore()
store.clear()
let terms = language == .chinese
? ["少数派", "工作流", "语音键盘"]
: ["VoiceFlow", "PromptKit", "OSGAgent"]
for term in terms {
for _ in 0..<4 {
store.recordCommittedText(term)
}
}
}
}
#endif
+2 -1
View File
@@ -48,7 +48,7 @@ struct ReleaseNotesSheet: View {
if isLoading {
ProgressView()
.tint(palette.accent)
.tint(palette.textPrimary)
}
}
} else {
@@ -70,6 +70,7 @@ struct ReleaseNotesSheet: View {
Button(AppL10n.string("common.done", language: language)) {
dismiss()
}
.tint(palette.textPrimary)
}
}
}
@@ -93,10 +93,6 @@ struct SettingsCredentialRow: View {
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 38)
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func iconButton(systemName: String, label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
@@ -157,8 +153,8 @@ struct SettingsModelPickerRow: View {
.onDisappear { invalidateRequest() }
}
/// Editable model id + trailing menu chevron in one well (same chrome as
/// Mac `MacPickerFieldBox`). Chevron is overlaid so it stays inside the border.
/// Editable model id + trailing menu chevron in one well. Chevron is
/// overlaid so it stays inside the field surface.
private var comboField: some View {
let shape = RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
let chevronWidth: CGFloat = 28
@@ -173,7 +169,6 @@ struct SettingsModelPickerRow: View {
.padding(.trailing, chevronWidth + Spacing.sm)
.frame(maxWidth: .infinity, minHeight: controlHeight, alignment: .leading)
.background(palette.surfaceElevated, in: shape)
.overlay(shape.stroke(palette.divider, lineWidth: 0.5))
Menu {
if models.isEmpty {
+9
View File
@@ -24,6 +24,7 @@ private enum SettingsRoute: Hashable {
case general
case aiAgent
case clipboard
case diagnostics
case about
}
@@ -144,6 +145,8 @@ struct SettingsView: View {
AIAgentSettingsView(config: config)
case .clipboard:
ClipboardSettingsView(config: config)
case .diagnostics:
FlowDiagnosticsSettingsView()
case .about:
AboutSettingsView(config: config)
}
@@ -314,6 +317,10 @@ struct SettingsView: View {
Divider().background(palette.divider)
settingsRouteButton(.diagnostics, title: "settings.diagnostics.title")
Divider().background(palette.divider)
// Opens the remote release-notes sheet (same as post-upgrade prompt).
SettingsVersionRow()
}
@@ -356,9 +363,11 @@ struct SettingsView: View {
private struct SettingsSheetDismissButton: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
var body: some View {
Button("common.done") { dismiss() }
.tint(palette.textPrimary)
}
}
+108 -57
View File
@@ -12,32 +12,92 @@ struct TypingInputSettingsView: View {
@ObservedObject private var config = ProviderConfig.shared
@ObservedObject private var configuration = TypingInputConfiguration.shared
@ObservedObject private var deployment = RimeDeploymentController.shared
@State private var showClearHabitsConfirmation = false
private var isDeploying: Bool { deployment.isDeploying }
var body: some View {
List {
Section {
Picker(
selection: $configuration.schema
ScrollView {
LazyVStack(alignment: .leading, spacing: CardLayoutMetrics.sectionSpacing) {
settingsSection(
title: AppL10n.string(
"settings.typingInput.schema.section",
language: config.uiLanguage
)
) {
ForEach(TypingInputSchema.allCases) { schema in
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
.font(TypeStyle.body)
.tag(schema)
}
} label: {
Text(AppL10n.string("settings.typingInput.schema.picker", language: config.uiLanguage))
.font(TypeStyle.body)
}
.pickerStyle(.inline)
} header: {
Text(AppL10n.string("settings.typingInput.schema.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
schemaOptionsCard
}
Section {
settingsSection(
title: AppL10n.string(
"settings.typingInput.fuzzy.section",
language: config.uiLanguage
),
footer: AppL10n.string(
"settings.typingInput.fuzzy.footer",
language: config.uiLanguage
)
) {
fuzzyPairsCard
}
settingsSection(
title: AppL10n.string(
"settings.typingInput.resources.section",
language: config.uiLanguage
)
) {
resourcesCard
}
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.lg)
}
.scrollClipDisabled()
.background(palette.background)
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
}
private var schemaOptionsCard: some View {
VStack(spacing: 0) {
ForEach(TypingInputSchema.allCases) { schema in
schemaOptionRow(schema)
if schema != TypingInputSchema.allCases.last {
Divider().background(palette.divider)
}
}
}
.surfaceCard()
}
private func schemaOptionRow(_ schema: TypingInputSchema) -> some View {
let isSelected = configuration.schema == schema
return Button {
guard !isSelected else { return }
configuration.schema = schema
} label: {
HStack(spacing: Spacing.sm) {
Text(AppL10n.string(schema.labelKey, language: config.uiLanguage))
.font(.system(size: 15, weight: isSelected ? .semibold : .regular))
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
}
}
.settingsListRow()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityValue(isSelected ? "selected" : "notSelected")
}
private var fuzzyPairsCard: some View {
VStack(spacing: 0) {
ForEach(PinyinFuzzyPair.allCases) { pair in
Toggle(
isOn: Binding(
@@ -51,16 +111,18 @@ struct TypingInputSettingsView: View {
Text(pair.displayName)
.font(TypeStyle.body)
}
.settingsListRow()
if pair != PinyinFuzzyPair.allCases.last {
Divider().background(palette.divider)
}
} header: {
Text(AppL10n.string("settings.typingInput.fuzzy.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
} footer: {
Text(AppL10n.string("settings.typingInput.fuzzy.footer", language: config.uiLanguage))
.font(TypeStyle.caption2)
}
}
.surfaceCard()
}
Section {
private var resourcesCard: some View {
VStack(spacing: 0) {
HStack {
Text(AppL10n.string("settings.typingInput.resources.status", language: config.uiLanguage))
.font(TypeStyle.body)
@@ -76,49 +138,38 @@ struct TypingInputSettingsView: View {
)
}
}
.settingsListRow()
Divider().background(palette.divider)
Button(AppL10n.string("settings.typingInput.resources.redeploy", language: config.uiLanguage)) {
deployUpdatedSchemas()
}
.font(TypeStyle.body)
.settingsListRow()
.disabled(isDeploying)
} header: {
Text(AppL10n.string("settings.typingInput.resources.section", language: config.uiLanguage))
.font(TypeStyle.caption2)
}
.surfaceCard()
}
Section {
Button(AppL10n.string("settings.typingInput.habits.clear", language: config.uiLanguage)) {
showClearHabitsConfirmation = true
private func settingsSection<Content: View>(
title: String? = nil,
footer: String? = nil,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
if let title {
Text(title)
.cardSectionLabel()
}
.font(TypeStyle.body)
.disabled(isDeploying)
.foregroundStyle(palette.danger)
} footer: {
Text(AppL10n.string("settings.typingInput.habits.footer", language: config.uiLanguage))
content()
if let footer {
Text(footer)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
}
.listSectionSpacing(CardLayoutMetrics.sectionSpacing)
.scrollContentBackground(.hidden)
.background(palette.background)
.navigationTitle(AppL10n.string("settings.typingInput.title", language: config.uiLanguage))
.navigationBarTitleDisplayMode(.inline)
.confirmationDialog(
AppL10n.string("settings.typingInput.habits.clear.title", language: config.uiLanguage),
isPresented: $showClearHabitsConfirmation,
titleVisibility: .visible
) {
Button(
AppL10n.string("settings.typingInput.habits.clear.confirm", language: config.uiLanguage),
role: .destructive
) {
deployment.clearTypingHabits()
}
Button(AppL10n.string("common.cancel", language: config.uiLanguage), role: .cancel) {}
} message: {
Text(AppL10n.string("settings.typingInput.habits.clear.message", language: config.uiLanguage))
}
}
private var hasDeploymentError: Bool {
+29 -14
View File
@@ -38,14 +38,14 @@
"onboarding.polish.localHint" = "Fill in an API key to enable AI polish. Without a key, raw ASR text is inserted.";
"onboarding.experience.intro.eyebrow" = "AI AGENT VOICE KEYBOARD";
"onboarding.experience.intro.title" = "Speak. Its written.";
"onboarding.experience.intro.subtitle" = "More than dictation. OSGKeyboard understands, writes, translates, and edits—then its AI Agent helps with what comes next.";
"onboarding.experience.intro.subtitle" = "The best voice keyboard, with your best personal AI Agent.";
"onboarding.experience.intro.action" = "Get Started";
"onboarding.experience.privacy.local.title" = "On-device transcription available";
"onboarding.experience.privacy.local.body" = "With the local engine, speech recognition stays on your device.";
"onboarding.experience.privacy.cloud.title" = "Clear cloud boundaries";
"onboarding.experience.privacy.cloud.body" = "Audio or text is sent only when you choose cloud recognition or an AI feature, and only to the configured service.";
"onboarding.experience.privacy.anywhere.title" = "Your AI Agent, in any app";
"onboarding.experience.privacy.anywhere.body" = "Dictate, translate, edit, and act from chat, email, notes, or code.";
"onboarding.experience.promise.voice.title" = "Smart voice keyboard";
"onboarding.experience.promise.voice.body" = "Type 6× faster with multiple polishing styles.";
"onboarding.experience.promise.agent.title" = "Personal AI Agent";
"onboarding.experience.promise.agent.body" = "Translate, summarize, and learn your tone to write smart replies.";
"onboarding.experience.promise.privacy.title" = "Privacy protected";
"onboarding.experience.promise.privacy.body" = "Bring your own AI API key and stay in control of your privacy.";
"onboarding.experience.permissions.title" = "Let us hear you";
"onboarding.experience.permissions.subtitle" = "Voice input needs microphone and speech recognition access. Two system prompts will appear next.";
"onboarding.experience.permissions.systemHint" = "These permissions are used only when you start voice input.";
@@ -68,7 +68,8 @@
"onboarding.experience.keyboardSwitch.checkSettings" = "Check Full Access in Settings";
"onboarding.experience.keyboardSwitch.waitingAction" = "Waiting for Verification";
"onboarding.experience.practice.skip" = "Skip for Now";
"onboarding.experience.practice.sample" = "Ill arrive tomorrow afternoon and message you when Im there.";
"onboarding.experience.practice.translateSample" = "我明天下午到,到了以后给你消息。";
"onboarding.experience.practice.replySample" = "Ill arrive tomorrow afternoon and message you when Im there.";
"onboarding.experience.practice.editorTitle" = "New Message";
"onboarding.experience.practice.permissionsTitle" = "Enable voice access";
"onboarding.experience.practice.permissionsSubtitle" = "Go back to finish permission setup, then try your first voice input.";
@@ -95,17 +96,18 @@
"onboarding.experience.practice.readAloud" = "Read aloud";
"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 sample below, tap Translate in OSGKeyboard, then insert the result into the field.";
"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" = "Use the same sample, tap Reply in the keyboard, then insert the generated response.";
"onboarding.experience.practice.replySubtitle" = "Copy the sample below, tap Reply in the keyboard, then insert the generated response.";
"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: “Whats in the news today?”";
"onboarding.experience.practice.copyAction" = "Copy Sample";
"onboarding.experience.practice.copied" = "Copied. Switch to OSGKeyboard.";
"onboarding.experience.practice.waiting" = "Waiting for you to complete this step in the keyboard…";
"onboarding.experience.login.title" = "Sign in for 1,000 credits";
"onboarding.experience.login.subtitle" = "Youve completed all four lessons. Sign in to keep using OSG managed AI without entering an API key.";
"onboarding.experience.login.body" = "If this device is eligible for the first-time signup reward, 1,000 credits are added automatically. Device integrity checks prevent duplicate claims.";
"onboarding.experience.login.subtitle" = "Sign in for free credits and start using AI without an API key.";
"onboarding.experience.login.body" = "Register for the first time to get free credits, then invite friends to earn more.";
"onboarding.experience.login.skip" = "Not Now";
"onboarding.experience.login.signedIn" = "Signed in. Your credits are up to date.";
"onboarding.experience.login.balance %lld" = "Current balance: %lld credits";
@@ -279,6 +281,18 @@
"settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step.";
"settings.about.title" = "About";
"settings.version.title" = "Version";
"settings.diagnostics.title" = "Startup Diagnostics";
"settings.diagnostics.section" = "Saved Reports";
"settings.diagnostics.count" = "%d startup failure reports";
"settings.diagnostics.empty" = "No startup failures have been saved.";
"settings.diagnostics.latest" = "Latest: %@";
"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.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.";
"releaseNotes.title" = "What's New";
"releaseNotes.openHint" = "View release notes for this version";
"releaseNotes.unavailable.title" = "Cannot load page";
@@ -833,11 +847,12 @@
"account.signedOut.body" = "Register and invite friends to earn credits.";
"account.signedOut.localUnaffected" = "On-device features and your own API keys work without an account.";
"home.accountRewards.signedOut.title" = "Sign in for free credits";
"home.accountRewards.signedOut.summary" = "Eligible new users receive 1,000 credits; after an invited friend completes qualifying usage, you both receive 1,000 credits.";
"home.accountRewards.signedOut.summary" = "Register and invite friends to earn credits.";
"account.signIn.apple" = "Sign in with Apple";
"account.signIn.hint" = "Creates or opens your optional OSG account.";
"account.signIn.loading" = "Signing in…";
"account.error.sessionExpired" = "Your session expired. Sign in again to continue.";
"account.error.appleReauthenticationRequired" = "Sign in with Apple again to keep your account protected.";
"account.summary.section" = "Account";
"account.summary.signedIn" = "Signed in";
"account.summary.protected" = "Protected account";
@@ -905,7 +920,7 @@
"account.referral.friendRewardQualification" = "Your friend also receives %@ credits after qualifying usage";
"account.referral.shareShort" = "Share";
"account.referral.inviteTitle" = "Invite";
"account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits.";
"account.referral.equalRewardDescription" = "Invite friends—both receive 1,000 credits";
"account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits.";
"account.referral.summaryCompact" = "Rewarded %d · Pending %d";
"account.referral.preparingLink" = "Loading your invitation link…";
+29 -14
View File
@@ -38,14 +38,14 @@
"onboarding.polish.localHint" = "填写 API Key 才能开启 AI 润色;未填写时将直接插入原始识别结果。";
"onboarding.experience.intro.eyebrow" = "AI AGENT 语音输入法";
"onboarding.experience.intro.title" = "开口,即成文";
"onboarding.experience.intro.subtitle" = "不只是语音输入。OSGKeyboard 能听懂、成文、翻译和编辑,也能调用 AI Agent 帮你处理下一步。";
"onboarding.experience.intro.subtitle" = "最好的语音输入法,最好的贴身 AI Agent";
"onboarding.experience.intro.action" = "开始设置";
"onboarding.experience.privacy.local.title" = "支持设备端转写";
"onboarding.experience.privacy.local.body" = "使用本地引擎时,语音识别在你的设备上完成。";
"onboarding.experience.privacy.cloud.title" = "明确的云端边界";
"onboarding.experience.privacy.cloud.body" = "只有选择云端识别或 AI 功能时,相应的语音或文字才会发送给已配置的服务。";
"onboarding.experience.privacy.anywhere.title" = "AI Agent,随处可用";
"onboarding.experience.privacy.anywhere.body" = "在聊天、邮件、笔记或代码编辑中,随时口述、翻译、编辑和处理任务。";
"onboarding.experience.promise.voice.title" = "智能语音输入法";
"onboarding.experience.promise.voice.body" = "6 倍输入速度,各种润色方式";
"onboarding.experience.promise.agent.title" = "随身 AI Agent";
"onboarding.experience.promise.agent.body" = "翻译、总结,还能学习你的语气,帮你智能回复";
"onboarding.experience.promise.privacy.title" = "隐私有保障";
"onboarding.experience.promise.privacy.body" = "支持自带 AI API Key,隐私尽在掌握";
"onboarding.experience.permissions.title" = "让我们听见你";
"onboarding.experience.permissions.subtitle" = "语音输入需要麦克风和语音识别。接下来会依次出现两个系统提示。";
"onboarding.experience.permissions.systemHint" = "权限只在你主动使用语音输入时生效。";
@@ -68,7 +68,8 @@
"onboarding.experience.keyboardSwitch.checkSettings" = "返回设置检查完整访问";
"onboarding.experience.keyboardSwitch.waitingAction" = "等待验证";
"onboarding.experience.practice.skip" = "暂时跳过体验";
"onboarding.experience.practice.sample" = "我明天下午到,到了以后给你消息。";
"onboarding.experience.practice.translateSample" = "Ill arrive tomorrow afternoon and message you when Im there.";
"onboarding.experience.practice.replySample" = "我明天下午到,到了以后给你消息。";
"onboarding.experience.practice.editorTitle" = "新消息";
"onboarding.experience.practice.permissionsTitle" = "开启语音权限";
"onboarding.experience.practice.permissionsSubtitle" = "返回上一步完成权限设置,然后开始第一次语音输入。";
@@ -95,17 +96,18 @@
"onboarding.experience.practice.readAloud" = "请照着念";
"onboarding.experience.practice.voiceSample" = "今天是礼拜四,你打算去哪里玩?";
"onboarding.experience.practice.translateTitle" = "试试翻译";
"onboarding.experience.practice.translateSubtitle" = "复制下面的示例,再在 OSGKeyboard 中点击「翻译」。确认结果插入文本框。";
"onboarding.experience.practice.translateSubtitle" = "复制下方英文,在 OSGKeyboard 中将它翻译成中文,再把结果插入文本框。";
"onboarding.experience.practice.replyTitle" = "试试回复";
"onboarding.experience.practice.replySubtitle" = "使用同一段示例文字,点击键盘中的「回复」,然后插入生成的回复。";
"onboarding.experience.practice.replySubtitle" = "复制下方示例文字,点击键盘中的「回复」,插入生成的回复。";
"onboarding.experience.practice.askAITitle" = "问问 AI";
"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" = "四项体验已经完成。登录后可继续使用 OSG 托管 AI,无需填写 API Key";
"onboarding.experience.login.body" = "符合首次注册资格时,1000 积分会自动到账。设备完整性检查用于防止重复领取。";
"onboarding.experience.login.subtitle" = "登录获赠积分,无需 API Key 即可开始免费使用";
"onboarding.experience.login.body" = "首次注册,将免费获取积分,邀请好友,还可以获赠更多";
"onboarding.experience.login.skip" = "暂不登录";
"onboarding.experience.login.signedIn" = "登录成功,积分已刷新";
"onboarding.experience.login.balance %lld" = "当前余额:%lld 积分";
@@ -279,6 +281,18 @@
"settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。";
"settings.about.title" = "关于";
"settings.version.title" = "版本";
"settings.diagnostics.title" = "启动诊断";
"settings.diagnostics.section" = "已保存报告";
"settings.diagnostics.count" = "%d 份启动失败报告";
"settings.diagnostics.empty" = "尚未保存启动失败报告。";
"settings.diagnostics.latest" = "最近一次:%@";
"settings.diagnostics.export" = "导出诊断日志";
"settings.diagnostics.clear" = "清除诊断日志";
"settings.diagnostics.clear.title" = "清除诊断日志?";
"settings.diagnostics.clear.message" = "这将从本机永久删除所有 Flow 会话启动失败报告。";
"settings.diagnostics.clear.confirm" = "清除日志";
"settings.diagnostics.privacy.section" = "隐私";
"settings.diagnostics.privacy.body" = "报告会保留在本机,直到你主动导出或清除。报告仅包含启动状态、耗时、错误码和内存占用,不会保存录音、转写、剪贴板内容、提示词、API Key 或账号标识。";
"releaseNotes.title" = "更新说明";
"releaseNotes.openHint" = "查看本版本更新说明";
"releaseNotes.unavailable.title" = "无法加载页面";
@@ -832,11 +846,12 @@
"account.signedOut.body" = "注册和邀请好友,都可获赠积分";
"account.signedOut.localUnaffected" = "本地功能与自有 API Key 无需账号也可继续使用。";
"home.accountRewards.signedOut.title" = "登录领免费积分";
"home.accountRewards.signedOut.summary" = "符合资格的新用户可领 1,000 积分;邀请好友完成首次有效使用,双方各得 1,000 积分";
"home.accountRewards.signedOut.summary" = "注册和邀请好友,都可获赠积分";
"account.signIn.apple" = "通过 Apple 登录";
"account.signIn.hint" = "创建或打开可选的 OSG 账号。";
"account.signIn.loading" = "正在登录…";
"account.error.sessionExpired" = "登录状态已过期,请重新登录后继续。";
"account.error.appleReauthenticationRequired" = "为继续保护账号,请重新通过 Apple 登录。";
"account.summary.section" = "账号";
"account.summary.signedIn" = "已登录";
"account.summary.protected" = "账号已安全保护";
@@ -904,7 +919,7 @@
"account.referral.friendRewardQualification" = "好友也可获得 %@ 积分,完成首次有效使用后发放";
"account.referral.shareShort" = "分享";
"account.referral.inviteTitle" = "邀请";
"account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分";
"account.referral.equalRewardDescription" = "邀请好友,共同获得1000积分";
"account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。";
"account.referral.summaryCompact" = "已奖励 %d · 待确认 %d";
"account.referral.preparingLink" = "正在加载邀请链接…";
+13 -5
View File
@@ -371,7 +371,9 @@ struct TypingRootView: View {
}
private var expandChevronFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
colorScheme == .dark
? OSGColor.keyboardDarkInputFill
: OSGColor.fixedLightContent
}
// MARK: - Editing toolbar (iPad)
@@ -423,7 +425,9 @@ struct TypingRootView: View {
}
private var editingToolbarButtonFill: Color {
colorScheme == .dark ? Color(white: 0.30) : .white
colorScheme == .dark
? OSGColor.keyboardDarkInputFill
: OSGColor.fixedLightContent
}
/// UIKit-recycled labels no SwiftUI Button per candidate.
@@ -773,7 +777,9 @@ struct TypingRootView: View {
}
private var selectedCandidateFill: Color {
colorScheme == .dark ? Color(white: 0.36) : .white
colorScheme == .dark
? OSGColor.keyboardDarkInputFillRaised
: OSGColor.fixedLightContent
}
private var returnKeyFill: Color {
@@ -786,7 +792,9 @@ struct TypingRootView: View {
private var returnKeyBorder: Color {
if state.returnKeyRole.usesActionFill {
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
return colorScheme == .dark
? OSGColor.keyboardActionBorderDark
: OSGColor.keyboardActionBorderLight
}
return palette.divider
}
@@ -799,7 +807,7 @@ struct TypingRootView: View {
}
private var returnKeyTextColor: Color {
state.returnKeyRole.usesActionFill ? .white : keyTextColor
state.returnKeyRole.usesActionFill ? OSGColor.fixedLightContent : keyTextColor
}
/// The send key stays recognizable in both appearances without becoming neon.
+8 -8
View File
@@ -188,7 +188,7 @@ struct AIKeyboardView: View {
Button(action: state.confirmPendingAIAnswer) {
Image(systemName: "plus")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.frame(width: Layout.fieldActionWidth, height: 44)
.background(palette.accent, in: Capsule())
}
@@ -709,13 +709,13 @@ struct AIKeyboardView: View {
if state.assistantInsertionSucceeded {
Image(systemName: "checkmark")
.font(.system(size: 27, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
} else {
switch state.aiSession.phase {
case .listening:
waveform(color: .white)
waveform(color: OSGColor.fixedLightContent)
case .preparing, .recognizing, .generating:
ProgressView().tint(.white)
ProgressView().tint(OSGColor.fixedLightContent)
case .inactive, .idle, .ready, .awaitingSend, .inserted, .sent, .failed:
ordinaryMicrophoneContent
}
@@ -726,17 +726,17 @@ struct AIKeyboardView: View {
private var ordinaryMicrophoneContent: some View {
switch state.phase {
case .recording:
waveform(color: Color(red: 1, green: 0.78, blue: 0.78))
waveform(color: OSGColor.recordingWaveform)
case .requestingPermissions, .processing:
ProgressView().tint(.white)
ProgressView().tint(OSGColor.fixedLightContent)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 25, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
case .idle, .denied:
Image(systemName: "mic.fill")
.font(.system(size: 28, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.symbolEffect(.breathe, isActive: micIsHoldingForAI)
}
}
@@ -104,7 +104,7 @@ struct ClipboardEnableGuideView: View {
Button(action: onOpenSettings) {
ExtL10n.text("keyboard.clipboard.guide.cta")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(palette.accent, in: Capsule())
@@ -222,6 +222,7 @@ struct ClipboardHistoryPanelView: View {
}
.buttonStyle(.glass)
.buttonBorderShape(.capsule)
.tint(palette.textPrimary)
Button {
// Dismiss the popup before publishing an empty history
@@ -231,12 +232,13 @@ struct ClipboardHistoryPanelView: View {
} label: {
ExtL10n.text("keyboard.clipboard.clear.confirm")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.background)
.frame(maxWidth: .infinity)
.frame(height: 36)
}
.buttonStyle(.glassProminent)
.buttonBorderShape(.capsule)
.tint(palette.accent)
.tint(palette.textPrimary)
}
}
.padding(16)
+11 -3
View File
@@ -43,7 +43,11 @@ struct KeyboardBrandLogo: View {
.resizable()
.renderingMode(.template)
.scaledToFit()
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
.foregroundStyle(
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
)
.frame(
width: KeyboardTopBarMetrics.logoWidth,
height: KeyboardTopBarMetrics.logoHeight
@@ -222,13 +226,17 @@ struct KeyboardTopControls: View {
}
private var pressedFill: Color {
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
colorScheme == .dark
? OSGColor.keyboardDarkControlFill
: OSGColor.keyboardLightKeyPressed
}
/// NativeKeyboardKeyColors.fill
/// 宿
private var tabTrackFill: Color {
colorScheme == .dark ? Color(white: 0.12) : Color.black.opacity(0.12)
colorScheme == .dark
? OSGColor.keyboardDarkControlBorder
: OSGColor.keyboardLightControlBorder
}
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
+5 -5
View File
@@ -123,7 +123,7 @@ struct LastInputEditView: View {
Color.clear
if case .listening = state.editSession {
Capsule()
.stroke(Color.white.opacity(0.28), lineWidth: 1.5)
.stroke(OSGColor.recordListeningRing, lineWidth: 1.5)
.scaleEffect(1 + min(max(state.level, 0), 1) * 0.08)
.animation(Motion.soft, value: state.level)
}
@@ -161,16 +161,16 @@ struct LastInputEditView: View {
private var primaryIcon: some View {
switch state.editSession {
case .preparing, .processing, .applying, .appending:
ProgressView().tint(.white)
ProgressView().tint(OSGColor.fixedLightContent)
case .review:
Image(systemName: "checkmark")
.font(.system(size: 21, weight: .bold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
case .listening:
WaveformView(
level: state.level,
barCount: 7,
color: .white,
color: OSGColor.fixedLightContent,
active: true
)
.frame(width: 35, height: 22)
@@ -178,7 +178,7 @@ struct LastInputEditView: View {
default:
Image(systemName: "mic.fill")
.font(.system(size: 21, weight: .semibold))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
}
}
@@ -3,32 +3,37 @@
//
// Shared native-like key surface used by voice and typing action rows.
import OSGKeyboardShared
import SwiftUI
enum NativeKeyboardKeyColors {
static func fill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.32) : .white
colorScheme == .dark
? OSGColor.keyboardDarkKeyFill
: OSGColor.fixedLightContent
}
static func pressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? Color(white: 0.23) : Color(white: 0.84)
colorScheme == .dark
? OSGColor.keyboardDarkKeyPressed
: OSGColor.keyboardLightKeyPressed
}
static func text(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark ? .white : Color(red: 0.06, green: 0.06, blue: 0.08)
colorScheme == .dark ? OSGColor.fixedLightContent : OSGColor.keyboardLightText
}
/// Adaptive brand green: brighter in dark mode and deeper in light mode.
static func sendFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.286, green: 0.725, blue: 0.416)
: Color(red: 0.196, green: 0.549, blue: 0.298)
? OSGColor.keyboardDarkSend
: OSGColor.keyboardLightSend
}
static func sendPressedFill(for colorScheme: ColorScheme) -> Color {
colorScheme == .dark
? Color(red: 0.227, green: 0.627, blue: 0.353)
: Color(red: 0.157, green: 0.447, blue: 0.247)
? OSGColor.keyboardDarkSendPressed
: OSGColor.keyboardLightSendPressed
}
}
@@ -67,7 +67,9 @@ private struct ToolbarKeySurface<Content: View>: View {
case .standard:
return palette.divider
case .send:
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
return colorScheme == .dark
? OSGColor.keyboardActionBorderDark
: OSGColor.keyboardActionBorderLight
}
}
}
@@ -340,7 +342,7 @@ struct RectangularToolbarButton: View {
}
private var buttonForeground: Color {
isSend ? .white : NativeKeyboardKeyColors.text(for: colorScheme)
isSend ? OSGColor.fixedLightContent : NativeKeyboardKeyColors.text(for: colorScheme)
}
// / Button
@@ -1,7 +1,7 @@
// MonthlyUsageCalendar.swift
// OSGKeyboard · HostSupport
//
// Current-month dictation heat map. Every date remains visible; the accent
// Current-month dictation heat map. Every date remains visible; the monochrome
// circle's opacity communicates that day's character count relative to the
// busiest day in the same month.
@@ -158,21 +158,21 @@ public struct MonthlyUsageCalendar: View {
let isToday = calendar.isDateInToday(point.date)
let isFuture = calendar.startOfDay(for: point.date) > calendar.startOfDay(for: Date())
let opacity = fillOpacity(for: point.value, isFuture: isFuture)
let usesLightText = opacity >= 0.48
let usesContrastingText = opacity >= 0.48
return Text(day.formatted())
.font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded))
.foregroundStyle(
isFuture
? palette.textTertiary.opacity(0.45)
: (usesLightText ? palette.textOnAccent : palette.textPrimary)
: (usesContrastingText ? palette.background : palette.textPrimary)
)
.frame(width: cellDiameter, height: cellDiameter)
.background(palette.accent.opacity(opacity), in: Circle())
.background(palette.textPrimary.opacity(opacity), in: Circle())
.overlay {
if isToday {
Circle()
.stroke(palette.accent, lineWidth: 1.5)
.stroke(palette.textPrimary, lineWidth: 1.5)
}
}
.frame(maxWidth: .infinity)
@@ -188,7 +188,7 @@ public struct MonthlyUsageCalendar: View {
private func fillOpacity(for value: Int, isFuture: Bool) -> Double {
guard value > 0, !isFuture else { return 0 }
let ratio = min(max(Double(value) / Double(maximumValue), 0), 1)
return 0.14 + sqrt(ratio) * 0.62
return 0.05 + pow(ratio, 1.15) * 0.73
}
}
@@ -112,7 +112,7 @@ public struct SupportDeveloperSection: View {
}
.frame(maxWidth: .infinity)
.padding(.vertical, Spacing.sm)
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
}
.buttonStyle(.plain)
@@ -223,12 +223,12 @@ public struct UsageStatsCluster<Header: View>: View {
VStack(spacing: CardLayoutMetrics.compactItemSpacing) {
HStack(spacing: CardLayoutMetrics.compactItemSpacing) {
compactCell(
systemImage: "waveform.badge.microphone",
systemImage: "timer",
value: UsageStatisticsStore.formatDuration(dictationDurationSeconds, language: language),
label: SharedL10n.string("stat.dictationTime", language: language)
)
compactCell(
systemImage: "text.quote",
systemImage: "quote.bubble",
value: UsageStatisticsStore.formatCount(dictationCharacterCount, language: language),
label: SharedL10n.string("stat.words", language: language),
action: onOpenHistory
@@ -242,7 +242,7 @@ public struct UsageStatsCluster<Header: View>: View {
label: SharedL10n.string("stat.translation", language: language)
)
compactCell(
systemImage: "books.vertical",
systemImage: "book.pages",
value: UsageStatisticsStore.formatCount(dictionaryTermCount, language: language),
label: SharedL10n.string("stat.dictionary", language: language),
action: onOpenDictionary
@@ -292,9 +292,9 @@ public struct UsageStatsCluster<Header: View>: View {
return ZStack(alignment: .bottomTrailing) {
// Subtle watermark stays fully visible within the card edges.
Image(systemName: systemImage)
.font(.system(size: 26, weight: .semibold))
.foregroundStyle(palette.textPrimary.opacity(0.06))
.frame(width: 32, height: 32, alignment: .center)
.font(.system(size: 38, weight: .ultraLight))
.foregroundStyle(palette.textPrimary.opacity(0.10))
.frame(width: 44, height: 44, alignment: .center)
.offset(x: Spacing.xs, y: Spacing.xs)
VStack(alignment: .leading, spacing: Spacing.xxs) {
@@ -323,6 +323,7 @@ public struct UsageStatsCluster<Header: View>: View {
}
}
.clipShape(shape)
.cardElevation()
}
private var disclosureIndicator: some View {
@@ -539,22 +539,18 @@ public actor AccountAPIClient {
}
private func clearSession() async throws {
cachedSession = nil
didLoadSession = true
var storageFailed = false
do {
try await sessionVault.clearSession()
} catch {
storageFailed = true
}
do {
try await sessionVault.clearRefreshTransaction()
} catch {
storageFailed = true
}
if storageFailed {
// Keep the cached session aligned with Keychain so a transient
// deletion failure can be retried instead of resurrecting on launch.
throw AccountAPIError.secureStorage
}
cachedSession = nil
didLoadSession = true
// A stale operation identifier is harmless once its refresh token is
// gone; do not turn a successful credential purge into a failed logout.
try? await sessionVault.clearRefreshTransaction()
}
private func invalidateSession(ifAccessTokenMatches expectedAccessToken: String) async throws {
@@ -562,12 +558,7 @@ public actor AccountAPIClient {
current.accessToken == expectedAccessToken else {
return
}
do {
try await clearSession()
} catch {
publishSessionInvalidation()
throw error
}
publishSessionInvalidation()
}
+5 -5
View File
@@ -104,9 +104,9 @@ struct MacToggleStyle: ToggleStyle {
Capsule()
.fill(configuration.isOn ? palette.accent : offTrackColor)
Circle()
.fill(Color.white)
.fill(OSGColor.macToggleKnob)
.frame(width: 16, height: 16)
.shadow(color: .black.opacity(0.25), radius: 1, y: 1)
.shadow(color: OSGColor.macToggleKnobShadow, radius: 1, y: 1)
.padding(2)
}
.frame(width: 36, height: 20)
@@ -117,8 +117,8 @@ struct MacToggleStyle: ToggleStyle {
private var offTrackColor: Color {
colorScheme == .dark
? Color.white.opacity(0.18)
: Color.black.opacity(0.15)
? OSGColor.macToggleOffTrackDark
: OSGColor.macToggleOffTrackLight
}
}
@@ -411,7 +411,7 @@ struct MacHeaderActionButtonStyle: ButtonStyle {
configuration.label
.padding(.horizontal, Spacing.md)
.frame(height: MacMetrics.pageHeaderControlHeight)
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.background(
palette.accent.opacity(configuration.isPressed ? 0.82 : 1),
in: Capsule()
+1 -1
View File
@@ -71,7 +71,7 @@ struct MacDictationOverlayView: View {
Capsule(style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
.shadow(color: Color.black.opacity(0.22), radius: 14, y: 5)
.shadow(color: OSGColor.macOverlayShadow, radius: 14, y: 5)
.padding(Self.shadowMargin)
.contentShape(Capsule(style: .continuous))
// Manual drag: `isMovableByWindowBackground` doesn't work on a
+7 -3
View File
@@ -173,7 +173,7 @@ struct MacDictionaryView: View {
}
.padding(.horizontal, Spacing.sm)
.frame(width: 220, height: MacMetrics.pageHeaderControlHeight)
.background(palette.surface, in: Capsule())
.background(palette.formSurface, in: Capsule())
.overlay(Capsule().stroke(palette.divider, lineWidth: 0.5))
}
@@ -289,9 +289,13 @@ private struct MacDictionaryEntryEditor: View {
Button(MacL10n.string("mac.cancel", language: language)) {
dismiss()
}
Button(MacL10n.string("mac.save", language: language), action: save)
.tint(palette.textPrimary)
Button(action: save) {
Text(MacL10n.string("mac.save", language: language))
.foregroundStyle(palette.background)
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.tint(palette.textPrimary)
.disabled(trimmedTerm.isEmpty)
}
}
@@ -385,7 +385,7 @@ struct MacLocalASRModelSettingsView: View {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.download", language: lang),
fill: palette.accent,
foreground: .white
foreground: OSGColor.fixedLightContent
) {
modelVM.installModel(model)
}
+3 -1
View File
@@ -272,7 +272,9 @@ struct MacOnboardingView: View {
.resizable()
.scaledToFit()
.frame(width: 128, height: 128)
.foregroundStyle(colorScheme == .dark ? Color.white : palette.accent)
.foregroundStyle(
colorScheme == .dark ? OSGColor.fixedLightContent : palette.accent
)
.accessibilityLabel("OSGKeyboard")
.allowsHitTesting(false)
}
+1 -1
View File
@@ -514,7 +514,7 @@ private struct MacPolishStyleEditor: View {
.font(.body.monospaced())
.frame(minHeight: 320)
.padding(4)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.background(palette.formSurface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(palette.divider, lineWidth: 1)
+3 -3
View File
@@ -421,14 +421,14 @@ struct MacProviderModelRow: View {
HStack(spacing: Spacing.sm) {
Text(modelId)
.font(TypeStyle.mono)
.foregroundStyle(isSelected ? palette.accent : palette.textPrimary)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(palette.accent)
.foregroundStyle(palette.textPrimary)
}
}
.padding(.horizontal, Spacing.sm)
@@ -436,7 +436,7 @@ struct MacProviderModelRow: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Radius.small, style: .continuous)
.fill(isSelected ? palette.accentMuted : Color.clear)
.fill(isSelected ? palette.textPrimary.opacity(0.10) : Color.clear)
)
.contentShape(Rectangle())
}
@@ -56,6 +56,7 @@ struct MacSupportDeveloperTipRows: View {
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
tipManager.acknowledgePurchaseState()
}
.tint(palette.textPrimary)
} message: {
Text(SharedL10n.string("tip.thankYou.message", language: language))
}
@@ -66,6 +67,7 @@ struct MacSupportDeveloperTipRows: View {
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
tipManager.acknowledgePurchaseState()
}
.tint(palette.textPrimary)
} message: {
Text(errorMessage)
}
+18 -13
View File
@@ -30,6 +30,7 @@ enum MacSystemPalette {
ThemePalette(
background: resolved(dark ? darkBackground : warmBackground, dark: dark),
surface: resolved(dark ? darkSurface : warmSurface, dark: dark),
formSurface: resolved(dark ? darkFormSurface : warmFormSurface, dark: dark),
surfaceElevated: resolved(dark ? darkElevated : warmElevated, dark: dark),
surfaceMuted: resolved(dark ? darkMuted : warmMuted, dark: dark),
@@ -45,14 +46,14 @@ enum MacSystemPalette {
textPrimary: resolved(.labelColor, dark: dark),
textSecondary: resolved(.secondaryLabelColor, dark: dark),
textTertiary: resolved(.tertiaryLabelColor, dark: dark),
textOnAccent: Color.white,
textOnAccent: OSGColor.fixedLightContent,
divider: dark
? Color.white.opacity(0.08)
: Color.black.opacity(0.06),
? OSGColor.macDarkDivider
: OSGColor.lightDivider,
dividerStrong: dark
? Color.white.opacity(0.12)
: Color.black.opacity(0.10),
? OSGColor.macDarkDividerStrong
: OSGColor.lightDividerStrong,
recordRed: resolved(.systemRed, dark: dark),
recordBlue: resolved(.systemBlue, dark: dark)
@@ -62,13 +63,15 @@ enum MacSystemPalette {
// MARK: - Warm Light-mode surfaces (matched to iOS `Palette.light`)
/// #F2F1EE warm gray page background.
private static let warmBackground = NSColor(srgbRed: 0.949, green: 0.945, blue: 0.933, alpha: 1)
private static let warmBackground = NSColor(OSGColor.lightBackground)
/// #FCFBF9 warm off-white card/control surface.
private static let warmSurface = NSColor(srgbRed: 0.988, green: 0.984, blue: 0.976, alpha: 1)
private static let warmSurface = NSColor(OSGColor.lightSurface)
/// #FCFBF9 forms retain the ordinary card fill in light mode.
private static let warmFormSurface = warmSurface
/// #EBEAE7 slightly recessed elevated surface.
private static let warmElevated = NSColor(srgbRed: 0.922, green: 0.918, blue: 0.906, alpha: 1)
private static let warmElevated = NSColor(OSGColor.lightSurfaceElevated)
/// #EEEDE9 muted fill between background and surface.
private static let warmMuted = NSColor(srgbRed: 0.933, green: 0.929, blue: 0.918, alpha: 1)
private static let warmMuted = NSColor(OSGColor.lightSurfaceMuted)
// MARK: - Dark-mode surfaces (Apple standard elevated grays)
//
@@ -78,13 +81,15 @@ enum MacSystemPalette {
// clearly elevated above the background mirroring the iOS dark palette.
/// #1C1C1E page background.
private static let darkBackground = NSColor(srgbRed: 0.110, green: 0.110, blue: 0.118, alpha: 1)
private static let darkBackground = NSColor(OSGColor.macDarkBackground)
/// #2C2C2E card / control surface, clearly lighter than the background.
private static let darkSurface = NSColor(srgbRed: 0.173, green: 0.173, blue: 0.180, alpha: 1)
private static let darkSurface = NSColor(OSGColor.macDarkSurface)
/// #343438 interactive form fill between cards and elevated chrome.
private static let darkFormSurface = NSColor(OSGColor.macDarkFormSurface)
/// #3A3A3C elevated fill for selected / raised chrome.
private static let darkElevated = NSColor(srgbRed: 0.227, green: 0.227, blue: 0.235, alpha: 1)
private static let darkElevated = NSColor(OSGColor.macDarkSurfaceElevated)
/// #242426 muted fill between background and surface.
private static let darkMuted = NSColor(srgbRed: 0.141, green: 0.141, blue: 0.149, alpha: 1)
private static let darkMuted = NSColor(OSGColor.macDarkSurfaceMuted)
/// Resolves a (possibly dynamic) AppKit colour to its concrete value under
/// the requested appearance, so the two static palettes differ by value.
@@ -81,34 +81,134 @@ public struct SurfaceCardModifier: ViewModifier {
@Environment(\.themePalette) private var palette
private let enabled: Bool
private let elevated: Bool
public init(enabled: Bool = true) {
public init(enabled: Bool = true, elevated: Bool = true) {
self.enabled = enabled
self.elevated = elevated
}
@ViewBuilder
public func body(content: Content) -> some View {
if enabled {
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
content
let card = content
.background(
palette.surface,
elevated ? palette.surface : palette.formSurface,
in: shape
)
// Clip child backgrounds as well as the card surface. Without
// this, a full-width child can visually square off a corner.
.clipShape(shape)
if elevated {
card.cardElevation()
} else {
card
}
} else {
content
}
}
}
private struct CardElevationModifier: ViewModifier {
@Environment(\.colorScheme) private var colorScheme
let accented: Bool
@ViewBuilder
func body(content: Content) -> some View {
#if os(iOS)
// Layered low-opacity shadows create direction and length without
// turning the warm background gray.
content
.shadow(
color: nearShadowColor,
radius: 1,
x: 2,
y: 3
)
.shadow(
color: midShadowColor,
radius: 3,
x: 6,
y: 8
)
.shadow(
color: farShadowColor,
radius: 9,
x: 12,
y: 18
)
#else
content
#endif
}
private var nearShadowColor: Color {
guard accented else { return OSGColor.cardShadowNear }
return colorScheme == .dark
? OSGColor.selectedCardShadowNearDark
: OSGColor.selectedCardShadowNearLight
}
private var midShadowColor: Color {
guard accented else { return OSGColor.cardShadowMid }
return colorScheme == .dark
? OSGColor.selectedCardShadowMidDark
: OSGColor.selectedCardShadowMidLight
}
private var farShadowColor: Color {
guard accented else { return OSGColor.cardShadowFar }
return colorScheme == .dark
? OSGColor.selectedCardShadowFarDark
: OSGColor.selectedCardShadowFarLight
}
}
private struct CardListRowModifier: ViewModifier {
let bottomSpacing: CGFloat
let elevated: Bool
func body(content: Content) -> some View {
content
.surfaceCard(elevated: elevated)
.listRowInsets(
EdgeInsets(
top: 0,
leading: 0,
bottom: bottomSpacing,
trailing: 0
)
)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
}
public extension View {
func cardSectionLabel() -> some View {
modifier(CardSectionLabelModifier())
}
func surfaceCard(enabled: Bool = true) -> some View {
modifier(SurfaceCardModifier(enabled: enabled))
func surfaceCard(
enabled: Bool = true,
elevated: Bool = true
) -> some View {
modifier(SurfaceCardModifier(enabled: enabled, elevated: elevated))
}
func cardElevation(accented: Bool = false) -> some View {
modifier(CardElevationModifier(accented: accented))
}
/// Makes a native List/Form row render as an elevated app card while
/// retaining system scrolling, swipe actions, refresh, and keyboard logic.
func cardListRow(
bottomSpacing: CGFloat = 0,
elevated: Bool = true
) -> some View {
modifier(CardListRowModifier(bottomSpacing: bottomSpacing, elevated: elevated))
}
}
@@ -0,0 +1,171 @@
// ColorLibrary.swift
// OSGKeyboard · Shared Design System
//
// Canonical custom colour values used across the app, keyboard extension,
// previews, and platform chrome. Views should prefer semantic ThemePalette
// roles; use these fixed tokens only when a surface intentionally does not
// follow the active app appearance.
import SwiftUI
public enum OSGColor {
// MARK: - Brand
public static let brandAccent = color(0x3AA05A)
public static let aiTeal = color(0x2BAFA4)
/// Content that must remain white on colored or generated artwork.
public static let fixedLightContent = Color.white
public static let fixedDarkContent = Color.black
// MARK: - iOS app theme
public static let darkBackground = color(0x0A0A0B)
public static let darkSurface = color(0x18181B)
/// Interactive form fill: lighter than cards without looking elevated.
public static let darkFormSurface = color(0x202024)
public static let darkSurfaceElevated = color(0x27272A)
public static let darkSurfaceMuted = color(0x121215)
public static let darkDanger = color(0xFF453A)
public static let darkWarning = color(0xFFBF18)
public static let darkTextPrimary = color(0xFFFFFF)
public static let darkTextSecondary = color(0xB3B3B3)
public static let darkTextTertiary = color(0x808080)
public static let darkDivider = Color.white.opacity(0.06)
public static let darkDividerStrong = Color.white.opacity(0.10)
public static let lightBackground = color(0xF2F1EE)
public static let lightSurface = color(0xFCFBF9)
/// Light mode already has enough contrast, so forms retain the card fill.
public static let lightFormSurface = lightSurface
public static let lightSurfaceElevated = color(0xEBEAE7)
public static let lightSurfaceMuted = color(0xEEEDE9)
public static let lightDanger = color(0xFF3B30)
public static let lightWarning = color(0xFF9E18)
public static let lightTextPrimary = color(0x111118)
public static let lightTextSecondary = color(0x64646F)
public static let lightTextTertiary = color(0x8E8E9A)
public static let lightDivider = Color.black.opacity(0.06)
public static let lightDividerStrong = Color.black.opacity(0.10)
// MARK: - macOS app theme
public static let macDarkBackground = color(0x1C1C1E)
public static let macDarkSurface = color(0x2C2C2E)
public static let macDarkFormSurface = color(0x343438)
public static let macDarkSurfaceElevated = color(0x3A3A3C)
public static let macDarkSurfaceMuted = color(0x242426)
public static let macDarkDivider = Color.white.opacity(0.08)
public static let macDarkDividerStrong = Color.white.opacity(0.12)
public static let macToggleKnob = Color.white
public static let macToggleKnobShadow = Color.black.opacity(0.25)
public static let macToggleOffTrackDark = Color.white.opacity(0.18)
public static let macToggleOffTrackLight = Color.black.opacity(0.15)
public static let macOverlayShadow = Color.black.opacity(0.22)
// MARK: - Recording and generated media
public static let recordRed = color(0xFF3B30)
public static let recordBlue = color(0x007AFF)
public static let recordingWaveform = color(0xFFC7C7)
public static let recordDiscHairlineIdle = Color.white.opacity(0.08)
public static let recordDiscHairlineActive = Color.white.opacity(0.12)
public static let recordDiscOverlayStroke = Color.white.opacity(0.16)
public static let recordListeningRing = Color.white.opacity(0.28)
public static let pictureInPictureOutline = color(0xE5E7EB)
public static let pictureInPictureAccent = color(0x33C78C)
// MARK: - Keyboard surfaces
public static let keyboardDarkKeyFill = color(0x525252)
public static let keyboardDarkKeyPressed = color(0x3B3B3B)
public static let keyboardDarkInputFill = color(0x4D4D4D)
public static let keyboardDarkInputFillRaised = color(0x5C5C5C)
public static let keyboardDarkControlFill = color(0x383838)
public static let keyboardDarkControlBorder = color(0x1F1F1F)
public static let keyboardLightKeyPressed = color(0xD6D6D6)
public static let keyboardLightControlBorder = Color.black.opacity(0.12)
public static let keyboardActionBorderDark = Color.black.opacity(0.10)
public static let keyboardActionBorderLight = Color.black.opacity(0.08)
public static let keyboardLightText = color(0x0F0F14)
public static let keyboardDarkSend = color(0x49B96A)
public static let keyboardLightSend = color(0x328C4C)
public static let keyboardDarkSendPressed = brandAccent
public static let keyboardLightSendPressed = color(0x28723F)
// MARK: - Fixed dark presentation surfaces
public static let demoBackground = color(0x0F0F12)
public static let demoSelectionInactive = Color.gray
public static let accountAvatarOutline = Color.white.opacity(0.20)
public static let accountAvatarGradients: [[Color]] = [
[.indigo, .blue],
[.purple, .pink],
[.teal, .cyan],
[.orange, .red],
[.mint, .green]
]
// MARK: - Shared effects
public static let cardShadowNear = Color.black.opacity(0.018)
public static let cardShadowMid = Color.black.opacity(0.018)
public static let cardShadowFar = Color.black.opacity(0.025)
public static let selectedCardFillLeadingDark = brandAccent.opacity(0.24)
public static let selectedCardFillTrailingDark = brandAccent.opacity(0.15)
public static let selectedCardFillLeadingLight = brandAccent.opacity(0.18)
public static let selectedCardFillTrailingLight = brandAccent.opacity(0.11)
public static let selectedCardStrokeDark = brandAccent.opacity(0.26)
public static let selectedCardStrokeLight = brandAccent.opacity(0.18)
public static let selectedCardShadowNearDark = brandAccent.opacity(0.12)
public static let selectedCardShadowMidDark = brandAccent.opacity(0.075)
public static let selectedCardShadowFarDark = brandAccent.opacity(0.035)
public static let selectedCardShadowNearLight = brandAccent.opacity(0.075)
public static let selectedCardShadowMidLight = brandAccent.opacity(0.05)
public static let selectedCardShadowFarLight = brandAccent.opacity(0.03)
// MARK: - Animated mesh palettes
public static let meshAurora: [Color] = [
color(0x0F1221), // top-left
color(0x1A1433), // top-mid
color(0x0D1A2E), // top-right
color(0x1F1729), // left-mid
color(0x2E1C38), // center deepest violet
color(0x141F33), // right-mid
color(0x291A1A), // bottom-left amber hint
color(0x1A142E), // bottom-mid
color(0x0D1724) // bottom-right
]
public static let meshPolar: [Color] = [
color(0x0D121A),
color(0x171F2E),
color(0x121724),
color(0x1A212E),
color(0x212938),
color(0x141A29),
color(0x0F1A29),
color(0x171C2E),
color(0x0D121F)
]
public static let meshEmber: [Color] = [
color(0x140F0D),
color(0x24170F),
color(0x1A120F),
color(0x291A12),
color(0x382114),
color(0x1F1412),
color(0x1A0F0D),
color(0x26170F),
color(0x140F0D)
]
private static func color(_ hex: UInt32) -> Color {
Color(
red: Double((hex >> 16) & 0xFF) / 255,
green: Double((hex >> 8) & 0xFF) / 255,
blue: Double(hex & 0xFF) / 255
)
}
}
@@ -0,0 +1,53 @@
// ProgressTrack.swift
// OSGKeyboard · Shared Design System
//
// Shared linear progress track for consistent height, shape, and background.
import SwiftUI
public struct ProgressTrackSegment {
let fraction: Double
let color: Color
public init(fraction: Double, color: Color) {
self.fraction = fraction
self.color = color
}
}
public struct ProgressTrack: View {
@Environment(\.themePalette) private var palette
private let segments: [ProgressTrackSegment]
public init(segments: [ProgressTrackSegment]) {
self.segments = segments
}
public var body: some View {
GeometryReader { proxy in
ZStack(alignment: .leading) {
Capsule()
.fill(palette.surfaceElevated.opacity(0.55))
HStack(spacing: 0) {
ForEach(segments.indices, id: \.self) { index in
Rectangle()
.fill(segments[index].color)
.frame(
width: proxy.size.width * clampedFraction(
segments[index].fraction
)
)
}
}
.clipShape(Capsule())
}
}
.frame(height: 8)
}
private func clampedFraction(_ fraction: Double) -> Double {
min(max(fraction, 0), 1)
}
}
@@ -68,7 +68,7 @@ public struct RecordButton: View {
}
private var waveformColor: Color {
Color(red: 1.0, green: 0.78, blue: 0.78)
OSGColor.recordingWaveform
}
private enum Layout {
@@ -103,7 +103,10 @@ public struct RecordButton: View {
.animation(Motion.soft, value: level)
Circle()
.stroke(Color.white.opacity(isIdle ? 0.08 : 0.12), lineWidth: 0.5)
.stroke(
isIdle ? OSGColor.recordDiscHairlineIdle : OSGColor.recordDiscHairlineActive,
lineWidth: 0.5
)
.frame(width: Layout.outerRing, height: Layout.outerRing)
ZStack {
@@ -120,7 +123,7 @@ public struct RecordButton: View {
Circle()
.fill(discGradient)
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.stroke(OSGColor.recordDiscOverlayStroke, lineWidth: 1)
.blendMode(.overlay)
}
@@ -129,13 +132,13 @@ public struct RecordButton: View {
case .idleReady, .idleUnavailable:
Image(systemName: "mic.fill")
.font(.system(size: 36, weight: .medium))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
case .recording:
VStack(spacing: 3) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.foregroundStyle(OSGColor.fixedLightContent)
.monospacedDigit()
.contentTransition(.numericText())
.offset(y: 3)
+42 -36
View File
@@ -1,7 +1,8 @@
// Theme.swift
// OSGKeyboard · Design System
//
// Single source of truth for colour, spacing, corner radius, typography.
// Semantic theme roles, spacing, corner radius, and typography. Canonical
// custom colour values live in `ColorLibrary.swift`.
// Inspired by Dieter Rams ("less but better") and Apple Human Interface:
// every surface has a single purpose, every token earns its place, and
// the visual hierarchy is carried by *whitespace + one accent*, never by
@@ -19,6 +20,7 @@ import SwiftUI
public struct ThemePalette: Sendable, Equatable {
public let background: Color
public let surface: Color
public let formSurface: Color
public let surfaceElevated: Color
public let surfaceMuted: Color
@@ -47,35 +49,36 @@ public struct ThemePalette: Sendable, Equatable {
public enum Palette {
// Backgrounds
public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B
public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B
public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A
public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215
public static let background = OSGColor.darkBackground
public static let surface = OSGColor.darkSurface
public static let formSurface = OSGColor.darkFormSurface
public static let surfaceElevated = OSGColor.darkSurfaceElevated
public static let surfaceMuted = OSGColor.darkSurfaceMuted
// Accents
public static let accent = Color(red: 0.227, green: 0.627, blue: 0.353) // #3AA05A
public static let accent = OSGColor.brandAccent
public static let accentMuted = accent.opacity(0.18)
public static let accentGlow = accent.opacity(0.42)
public static let aiTeal = Color(red: 0.169, green: 0.686, blue: 0.643) // #2BAFA4
public static let aiTeal = OSGColor.aiTeal
// Semantic
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
public static let danger = OSGColor.darkDanger
public static let success = accent // unified brand green in UI
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
public static let warning = OSGColor.darkWarning
// Text
public static let textPrimary = Color.white
public static let textSecondary = Color(white: 0.7)
public static let textTertiary = Color(white: 0.50)
public static let textOnAccent = Color.black
public static let textPrimary = OSGColor.darkTextPrimary
public static let textSecondary = OSGColor.darkTextSecondary
public static let textTertiary = OSGColor.darkTextTertiary
public static let textOnAccent = OSGColor.fixedDarkContent
// Lines
public static let divider = Color.white.opacity(0.06)
public static let dividerStrong = Color.white.opacity(0.10)
public static let divider = OSGColor.darkDivider
public static let dividerStrong = OSGColor.darkDividerStrong
// Recording state
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
public static let recordBlue = Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
public static let recordRed = OSGColor.recordRed
public static let recordBlue = OSGColor.recordBlue
/// Canonical dark palette preserves every legacy literal above so
/// existing call sites that read `Palette.background` directly keep
@@ -84,6 +87,7 @@ public enum Palette {
public static let dark = ThemePalette(
background: background,
surface: surface,
formSurface: formSurface,
surfaceElevated: surfaceElevated,
surfaceMuted: surfaceMuted,
accent: accent,
@@ -105,25 +109,26 @@ public enum Palette {
/// Light palette warm gray backgrounds for daytime use.
public static let light = ThemePalette(
background: Color(red: 0.949, green: 0.945, blue: 0.933), // #F2F1EE warm gray
surface: Color(red: 0.988, green: 0.984, blue: 0.976), // #FCFBF9
surfaceElevated: Color(red: 0.922, green: 0.918, blue: 0.906), // #EBEAE7
surfaceMuted: Color(red: 0.933, green: 0.929, blue: 0.918), // #EEEDE9
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
aiTeal: Color(red: 0.169, green: 0.686, blue: 0.643), // #2BAFA4
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
success: Color(red: 0.227, green: 0.627, blue: 0.353), // same as accent
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
textTertiary: Color(red: 0.557, green: 0.557, blue: 0.604), // #8E8E9A
textOnAccent: Color.white,
divider: Color.black.opacity(0.06),
dividerStrong: Color.black.opacity(0.10),
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
recordBlue: Color(red: 0.000, green: 0.478, blue: 1.000) // #007AFF
background: OSGColor.lightBackground,
surface: OSGColor.lightSurface,
formSurface: OSGColor.lightFormSurface,
surfaceElevated: OSGColor.lightSurfaceElevated,
surfaceMuted: OSGColor.lightSurfaceMuted,
accent: OSGColor.brandAccent,
accentMuted: OSGColor.brandAccent.opacity(0.14),
accentGlow: OSGColor.brandAccent.opacity(0.32),
aiTeal: OSGColor.aiTeal,
danger: OSGColor.lightDanger,
success: OSGColor.brandAccent,
warning: OSGColor.lightWarning,
textPrimary: OSGColor.lightTextPrimary,
textSecondary: OSGColor.lightTextSecondary,
textTertiary: OSGColor.lightTextTertiary,
textOnAccent: OSGColor.fixedLightContent,
divider: OSGColor.lightDivider,
dividerStrong: OSGColor.lightDividerStrong,
recordRed: OSGColor.recordRed,
recordBlue: OSGColor.recordBlue
)
}
@@ -235,6 +240,7 @@ private struct CardSurfaceModifier: ViewModifier {
content
.padding(padding)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.cardElevation()
}
}
@@ -1,8 +1,8 @@
// UsageSurfaceCard.swift
// OSGKeyboard · Shared
//
// Flat semantic surface used by home / dashboard stats on every platform.
// Deliberately shadowless hierarchy comes from the semantic surface fill.
// Semantic surface used by home / dashboard stats on every platform.
// Shared card elevation keeps its depth consistent with the rest of the app.
import SwiftUI
@@ -30,5 +30,6 @@ public struct UsageSurfaceCard<Content: View>: View {
.padding(padding)
.background(palette.surface, in: shape)
.clipShape(shape)
.cardElevation()
}
}
@@ -437,6 +437,37 @@ final class AccountAPIClientTests: XCTestCase {
XCTAssertEqual(requests.single?.url?.path, "/v1/auth/logout")
}
func testLogoutClearFailureRetainsSessionForRetry() async throws {
let retained = makeAccountSession()
let store = InMemoryAccountSecurityStore(
session: retained,
sessionClearFailures: 1
)
let transport = QueueAccountTransport([])
let client = AccountAPIClient(
baseURL: URL(string: "https://account.test")!,
transport: transport,
sessionVault: store
)
do {
try await client.logout()
XCTFail("Expected the first Keychain deletion to fail")
} catch let error as AccountAPIError {
XCTAssertEqual(error, .secureStorage)
}
let sessionAfterFailure = await store.session
let cachedSessionAfterFailure = try await client.currentSession()
XCTAssertEqual(sessionAfterFailure, retained)
XCTAssertEqual(cachedSessionAfterFailure, retained)
try await client.logout()
let sessionAfterRetry = await store.session
XCTAssertNil(sessionAfterRetry)
}
func testDeleteAccountUsesAuthenticatedDeleteAndClearsPrivateSession() async throws {
let store = InMemoryAccountSecurityStore(session: makeAccountSession())
let transport = QueueAccountTransport([
@@ -3,6 +3,7 @@
//
// Pure invitation parsing and account coordinator state-machine coverage.
import AuthenticationServices
@testable import OSGKeyboard
import XCTest
@@ -184,7 +185,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
}
@@ -341,6 +343,40 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(loadCount, 2)
}
@MainActor
func testForcedRefreshQueuedDuringInFlightRefreshRunsAfterward() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let clock = MutableAccountClock(now: Date(timeIntervalSince1970: 1_000))
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
accountLoadDelayNanoseconds: 20_000_000
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
now: { clock.now }
)
await coordinator.restoreIfNeeded()
clock.now = clock.now.addingTimeInterval(601)
let staleRefresh = Task { await coordinator.refreshAccountData() }
while !coordinator.isRefreshingAccountData {
await Task.yield()
}
await coordinator.refreshAccountData(force: true)
await staleRefresh.value
let loadCount = await service.loadCount()
XCTAssertEqual(loadCount, 3)
}
@MainActor
func testFailedAccountRefreshKeepsLastSuccessfulSnapshotAndTime() async {
let account = AccountSession(
@@ -488,7 +524,8 @@ final class AccountCenterViewModelTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
@@ -570,6 +607,128 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(signOutCount, 1)
}
@MainActor
func testMissingAppleIdentifierRequiresOneTimeReauthentication() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
appleCredentialState: .reauthenticationRequired
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(
coordinator.operationErrorKey,
"account.error.appleReauthenticationRequired"
)
}
@MainActor
func testRevocationSignOutFailureKeepsSessionForRetry() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
shouldFailSignOut: true,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
await coordinator.validateAppleCredentialState()
XCTAssertEqual(coordinator.sessionPhase, .signedIn(account))
XCTAssertEqual(coordinator.operationErrorKey, "account.error.signOut")
}
@MainActor
func testCredentialRevocationNotificationClearsSignedInState() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let center = NotificationCenter()
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore(),
notificationCenter: center
)
await coordinator.restoreIfNeeded()
center.post(
name: ASAuthorizationAppleIDProvider.credentialRevokedNotification,
object: nil
)
for _ in 0..<100 where coordinator.isSignedIn {
await Task.yield()
}
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
XCTAssertEqual(coordinator.operationErrorKey, "account.error.sessionExpired")
}
@MainActor
func testRevocationPreventsInFlightProfileUpdateFromRestoringSession() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000,
displayName: "Before"
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
profileUpdateDelayNanoseconds: 20_000_000,
appleCredentialState: .revoked
)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: InMemoryPendingReferralStore()
)
await coordinator.restoreIfNeeded()
let update = Task { await coordinator.updateDisplayName("After") }
while coordinator.operation != .updatingProfile {
await Task.yield()
}
await coordinator.validateAppleCredentialState()
let didUpdate = await update.value
XCTAssertFalse(didUpdate)
XCTAssertEqual(coordinator.sessionPhase, .signedOut)
}
private func makeReferral(status: AccountReferralStatus) -> AccountReferral {
AccountReferral(
id: UUID(),
@@ -642,9 +801,11 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool
private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64
private let signInDelayNanoseconds: UInt64
private let accountLoadDelayNanoseconds: UInt64
private let profileUpdateDelayNanoseconds: UInt64
private var remainingRestoreFailures: Int
private let storedAppleCredentialState: AccountAppleCredentialState
private var redeemed: [String] = []
@@ -662,10 +823,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false,
shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0,
signInDelayNanoseconds: UInt64 = 0,
restoreFailureCount: Int = 0,
accountLoadDelayNanoseconds: UInt64 = 0,
profileUpdateDelayNanoseconds: UInt64 = 0,
appleCredentialState: AccountAppleCredentialState = .unknown
) {
restored = restoredSession
@@ -674,10 +837,12 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption
self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds
self.signInDelayNanoseconds = signInDelayNanoseconds
remainingRestoreFailures = restoreFailureCount
self.accountLoadDelayNanoseconds = accountLoadDelayNanoseconds
self.profileUpdateDelayNanoseconds = profileUpdateDelayNanoseconds
self.storedAppleCredentialState = appleCredentialState
}
@@ -703,6 +868,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
try await Task.sleep(nanoseconds: signOutDelayNanoseconds)
}
logoutCount += 1
if shouldFailSignOut {
throw AccountServiceSpyError.failed
}
}
func deleteAccount(with payload: AppleAuthorizationPayload) async throws {
@@ -739,6 +907,20 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
return centerSnapshot
}
func updateDisplayName(_ displayName: String) async throws -> AccountSession {
if profileUpdateDelayNanoseconds > 0 {
try await Task.sleep(nanoseconds: profileUpdateDelayNanoseconds)
}
guard let signedInAccount else {
throw AccountIntegrationError.unavailable
}
return AccountSession(
accountID: signedInAccount.accountID,
createdAtEpochSeconds: signedInAccount.createdAtEpochSeconds,
displayName: displayName
)
}
func redeemReferral(code: String) async throws {
if shouldFailRedemption {
throw AccountServiceSpyError.failed
@@ -0,0 +1,126 @@
// FlowFailureLogStoreTests.swift
// OSGKeyboardTests
import Foundation
@testable import OSGKeyboard
import XCTest
final class FlowFailureLogStoreTests: XCTestCase {
func testFailureReportKeepsOnlyTenSecondsAndRedactsSensitiveMetadata() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let clock = MutableFailureLogClock(
Date(timeIntervalSince1970: 1_800_000_000)
)
let store = FlowFailureLogStore(
directoryURL: directory,
now: { clock.current }
)
store.record("event=tooOld")
clock.advance(by: 11)
store.record(
"event=current sessionId=550e8400-e29b-41d4-a716-446655440000 "
+ "access_token=secret container=/private/var/mobile/Containers/example"
)
let url = try XCTUnwrap(
store.persistStartupFailure(
reason: "timedOut",
context: ["authorization": "bearer=secret"]
)
)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let report = try decoder.decode(
FlowStartupFailureReport.self,
from: Data(contentsOf: url)
)
XCTAssertEqual(report.reason, "timedOut")
XCTAssertEqual(report.events.count, 1)
XCTAssertFalse(report.events[0].message.contains("tooOld"))
XCTAssertFalse(report.events[0].message.contains("550e8400"))
XCTAssertFalse(report.events[0].message.contains("secret"))
XCTAssertFalse(report.events[0].message.contains("/private/var/mobile"))
XCTAssertEqual(report.events[0].message.components(separatedBy: "<uuid>").count, 2)
}
func testReportRetentionKeepsNewestFilesWithinCountLimit() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let clock = MutableFailureLogClock(
Date(timeIntervalSince1970: 1_800_000_000)
)
let store = FlowFailureLogStore(
directoryURL: directory,
maxReportCount: 2,
now: { clock.current }
)
for index in 0..<3 {
store.record("event=failure\(index)")
XCTAssertNotNil(
store.persistStartupFailure(
reason: "timedOut",
context: ["index": "\(index)"]
)
)
clock.advance(by: 1)
}
XCTAssertEqual(store.reportURLs().count, 2)
}
func testExportCombinesReportsAndClearRemovesAllFiles() throws {
let directory = temporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let store = FlowFailureLogStore(directoryURL: directory)
store.record("event=failure")
XCTAssertNotNil(
store.persistStartupFailure(
reason: "notPossible",
context: [:]
)
)
let exportURL = try XCTUnwrap(store.makeExportURL())
let exportText = try String(contentsOf: exportURL, encoding: .utf8)
XCTAssertTrue(exportText.contains("\"reports\""))
XCTAssertTrue(exportText.contains("\"notPossible\""))
store.deleteAllReports()
XCTAssertTrue(store.reportURLs().isEmpty)
XCTAssertFalse(FileManager.default.fileExists(atPath: exportURL.path))
}
private func temporaryDirectory() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(
"FlowFailureLogStoreTests-\(UUID().uuidString)",
isDirectory: true
)
}
}
private final class MutableFailureLogClock: @unchecked Sendable {
private let lock: NSLock
private var value: Date
init(_ date: Date) {
value = date
lock = NSLock()
}
var current: Date {
lock.withLock { value }
}
func advance(by interval: TimeInterval) {
lock.withLock {
value = value.addingTimeInterval(interval)
}
}
}
+2 -1
View File
@@ -314,7 +314,8 @@ final class ReferralProfileTests: XCTestCase {
with: AppleAuthorizationPayload(
identityToken: "identity",
authorizationCode: "authorization",
nonce: "nonce"
nonce: "nonce",
userIdentifier: "apple-user"
)
)
@@ -13,17 +13,20 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
private(set) var clearSessionCount = 0
private(set) var clearRefreshTransactionCount = 0
private var remainingSessionSaveFailures: Int
private var remainingSessionClearFailures: Int
init(
session: AccountSession? = nil,
refreshTransaction: AccountRefreshTransaction? = nil,
keyState: AppAttestKeyState? = nil,
sessionSaveFailures: Int = 0
sessionSaveFailures: Int = 0,
sessionClearFailures: Int = 0
) {
self.session = session
self.refreshTransaction = refreshTransaction
self.keyState = keyState
self.remainingSessionSaveFailures = sessionSaveFailures
self.remainingSessionClearFailures = sessionClearFailures
}
func loadSession() async throws -> AccountSession? {
@@ -39,6 +42,10 @@ actor InMemoryAccountSecurityStore: AccountSessionVault, AppAttestKeyStateStorin
}
func clearSession() async throws {
if remainingSessionClearFailures > 0 {
remainingSessionClearFailures -= 1
throw AccountAPIError.secureStorage
}
session = nil
clearSessionCount += 1
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

+123 -66
View File
@@ -178,98 +178,155 @@
<p class="empty" id="empty-zh">暂无与当前版本匹配的更新说明。</p>
<p class="empty" id="empty-en">No release notes match this app version.</p>
<!-- ========== 2.0.1 ========== -->
<article class="version-block" data-version="2.0.1">
<div class="version-chip">2.0.1</div>
<!-- ========== 2.0.2 ========== -->
<article class="version-block" data-version="2.0.2">
<div class="version-chip">2.0.2</div>
<div class="lang-block" data-lang="zh">
<section class="section">
<h2>更安全、更顺畅的 AI 体验</h2>
<h2>UI 与交互全面升级</h2>
<ul>
<li>未登录、退出账号或会话过期时,所有 AI 功能会自动切回<strong>用户自备 API Key</strong>,并阻止缓存的托管 Grant 继续消耗积分</li>
<li>首次使用流程更加精简,可依次体验听写、翻译、智能回复和语音提问,再决定是否登录</li>
<li>官方 AI 技能目录可独立更新,无需等待新的 App 版本</li>
<li>修复后台挂起时的埋点数据库锁、重复显示购买成功提示,以及账号和设置刷新不稳定的问题</li>
<li>iPhone 使用 iOS 26 原生 Liquid Glass 标签栏与页面过渡,切换标签时会保留各页面的导航状态</li>
<li>技能页改为更清晰的已安装与未安装列表;点按技能进入完整详情页,集中管理安装、快捷指令配置和自定义技能编辑</li>
<li>重新整理首页、账号、AI 服务和个人词库中的信息层级;缺少必要配置时,会直接说明原因并提供下一步入口</li>
</ul>
</section>
<section class="section">
<div class="group">
<h2>新增 Apple 账号登录</h2>
<div class="wn-media wide">
<img src="assets/whats-new/account-sign-in-zh.png" alt="OSGKeyboard 通过 Apple 登录账号的真实界面卡片" loading="lazy" decoding="async">
</div>
<ul>
<li>支持通过 Apple 登录创建或打开 OSG 账号,集中查看积分、邀请奖励和购买记录</li>
<li>登录后可直接通过 OSG 积分使用托管语音与 AI 服务,也可通过 App Store 购买积分包</li>
<li>本地听写和用户自备 API Key 仍可独立使用,无需登录</li>
</ul>
</div>
<div class="group">
<h2>邀请好友,双方得积分</h2>
<div class="wn-media wide">
<img src="assets/whats-new/referral-rewards-zh.png" alt="OSGKeyboard 账号中的积分余额与邀请奖励" loading="lazy" decoding="async">
</div>
<ul>
<li>登录可领取注册奖励;每成功邀请一位好友,你和好友都可以获得积分</li>
<li>邀请链接与积分余额会跟随账号保持同步</li>
</ul>
</div>
</section>
<section class="section">
<h2>新增个人专属风格</h2>
<div class="wn-media wide">
<img src="assets/whats-new/personal-style-card-zh.png" alt="润色风格页面中的生成专属风格真实界面卡片" loading="lazy" decoding="async">
</div>
<ul>
<li>可从自己的听写表达与明确修改中学习说话风格,生成专属于你的措辞和表达习惯</li>
<li>个人风格可应用于剪贴板回复,在不改变原意的前提下调整用词与节奏,让回复更像你自己</li>
<li>用于学习的文字语料仅在你主动点击生成时发送给所选 AI 服务,生成结果需要确认后才会保存和启用</li>
</ul>
</section>
<section class="section">
<h2>新增智能剪贴板技能</h2>
<ul>
<li>端侧模型与本地规则会对剪贴板内容进行基础语义识别,并从完整技能目录中智能推荐最多五个相关操作</li>
<li>可识别任务、邀约、问句、情绪和语言,推荐回复、翻译、总结、澄清、共情、商务回复或整理清单等技能</li>
<li>复制单个电话号码时可直接拨号或新建联系人;复制网页链接时可直接打开,或提取公开网页内容进行总结</li>
<li>识别和推荐过程在设备端完成;只有主动运行需要 AI 的技能时,相关内容才会发送给所选服务</li>
</ul>
</section>
<section class="section">
<h2>新增自动推荐个人热词</h2>
<div class="wn-media wide">
<img src="assets/whats-new/personal-terms-card-zh.png" alt="首页个性词库中的个人热词推荐真实界面卡片" loading="lazy" decoding="async">
</div>
<ul>
<li>根据键盘中反复输入的新词自动生成个人热词推荐,让常用名称、术语和表达更容易再次输入</li>
<li>推荐词必须由你明确确认后才会加入个人词库;本地学习不会记录完整击键内容</li>
</ul>
</section>
</div>
<div class="lang-block" data-lang="en">
<section class="section">
<h2>Safer, Smoother AI</h2>
<h2>UI and Interaction, Rebuilt</h2>
<ul>
<li>When signed out or after a session expires, every AI feature now returns to <strong>your own API key</strong> and cached managed grants can no longer consume credits</li>
<li>The shorter first-run guide demonstrates dictation, translation, smart reply, and voice questions before the optional sign-in step</li>
<li>The official AI skill catalog can refresh without waiting for a new App Store release</li>
<li>Fixes analytics database locks during suspension, repeated purchase confirmations, and unreliable account or settings refreshes</li>
<li>iPhone now uses the native iOS 26 Liquid Glass tab bar and transitions while preserving each tab's navigation state</li>
<li>Skills are organized into clearer Installed and Not Installed lists; tap one to manage installation, Shortcut setup, or custom-skill editing on a dedicated detail page</li>
<li>Home, Account, AI Service, and Personal Dictionary now have a clearer hierarchy, with direct explanations and next steps when setup is incomplete</li>
</ul>
</section>
</div>
</article>
<!-- ========== 2.0.0 ========== -->
<article class="version-block" data-version="2.0.0">
<div class="version-chip">2.0.0</div>
<div class="lang-block" data-lang="zh">
<section class="section">
<h2>全新积分体验</h2>
<div class="group">
<p class="group-title">邀请好友,双方得积分</p>
<h2>New Sign in with Apple</h2>
<div class="wn-media wide">
<img src="assets/whats-new/referral-rewards-zh.png" alt="OSGKeyboard 设置中的邀请奖励卡片" loading="lazy" decoding="async">
<img src="assets/whats-new/account-sign-in-en.png" alt="Real OSGKeyboard account card with Sign in with Apple" loading="lazy" decoding="async">
</div>
<ul>
<li>每成功邀请一位好友,你和好友各获得<strong>1000 积分</strong></li>
<li>可在设置的账号区域分享邀请链接</li>
</ul>
</div>
<div class="group">
<p class="group-title">OSG 积分模式</p>
<div class="wn-media wide">
<img src="assets/whats-new/osg-credits-zh.png" alt="OSGKeyboard AI 服务中已选择使用积分" loading="lazy" decoding="async">
</div>
<ul>
<li>使用 Apple 登录后,无需配置自己的 API Key,即可使用托管语音与 AI 服务</li>
<li>支持通过 App Store 购买积分包,积分余额自动同步</li>
<li>本地听写和自备 API Key 仍可独立使用,无需登录</li>
</ul>
</div>
</section>
</div>
<div class="lang-block" data-lang="en">
<section class="section">
<h2>A New Credits Experience</h2>
<div class="group">
<p class="group-title">Invite Friends, Earn Credits Together</p>
<div class="wn-media wide">
<img src="assets/whats-new/referral-rewards-en.png" alt="Referral rewards card in OSGKeyboard Settings" loading="lazy" decoding="async">
</div>
<ul>
<li>For every successful referral, you and your friend each receive <strong>1,000 credits</strong></li>
<li>Share your invitation link from the Account section in Settings</li>
</ul>
</div>
<div class="group">
<p class="group-title">OSG Credits Mode</p>
<div class="wn-media wide">
<img src="assets/whats-new/osg-credits-en.png" alt="Use Credits selected in OSGKeyboard AI Service" loading="lazy" decoding="async">
</div>
<ul>
<li>Sign in with Apple to use managed speech and AI services without configuring your own API key</li>
<li>Purchase credit packs through the App Store and keep your balance synchronized automatically</li>
<li>Use Sign in with Apple to create or open an OSG account and manage credits, referral rewards, and purchase history in one place</li>
<li>After signing in, use managed speech and AI services with OSG credits, with additional credit packs available through the App Store</li>
<li>Local dictation and user-owned API keys remain available independently, with no sign-in required</li>
</ul>
</div>
<div class="group">
<h2>Invite Friends, Earn Credits Together</h2>
<div class="wn-media wide">
<img src="assets/whats-new/referral-rewards-en.png" alt="Credit balance and referral rewards in an OSGKeyboard account" loading="lazy" decoding="async">
</div>
<ul>
<li>Sign in to receive the signup reward; for every successful referral, both you and your friend can earn credits</li>
<li>Your invitation link and credit balance stay synchronized with your account</li>
</ul>
</div>
</section>
<section class="section">
<h2>New Personal Style</h2>
<div class="wn-media wide">
<img src="assets/whats-new/personal-style-card-en.png" alt="Real Generate Your Style card on the Polish Styles page" loading="lazy" decoding="async">
</div>
<ul>
<li>Learn a personal speaking style from your dictation and explicit edits, capturing the wording and expression patterns that make your voice distinct</li>
<li>Apply that style to clipboard replies so wording and rhythm sound more like you without changing the original meaning</li>
<li>Learning text is sent to the selected AI service only when you explicitly tap Generate, and the result must be reviewed before it is saved and enabled</li>
</ul>
</section>
<section class="section">
<h2>New Smart Clipboard Skills</h2>
<ul>
<li>On-device models and local rules perform lightweight semantic recognition, then recommend up to five relevant actions from the complete skill catalog</li>
<li>Tasks, invitations, questions, sentiment, and language can surface skills such as Reply, Translate, Summarize, Clarify, Empathy, Business Reply, or Organize List</li>
<li>A single copied phone number can open calling or a prefilled new-contact form; a copied web link can open directly or be summarized from bounded public webpage content</li>
<li>Recognition and recommendation stay on device. Content is sent to the selected service only after you run a skill that requires AI</li>
</ul>
</section>
<section class="section">
<h2>New Automatic Personal-Term Suggestions</h2>
<div class="wn-media wide">
<img src="assets/whats-new/personal-terms-card-en.png" alt="Real personal-term suggestion card on Home" loading="lazy" decoding="async">
</div>
<ul>
<li>Repeated new terms from keyboard input become suggestions, making frequently used names, terminology, and expressions easier to type again</li>
<li>A suggestion joins Personal Dictionary only after your explicit confirmation; local learning does not record complete keystrokes</li>
</ul>
</section>
</div>
</article>
<!-- ========== 2.0.1 ========== -->
<!-- Consolidated into the cumulative 2.0.2 user-facing release notes. -->
<!-- ========== 2.0.0 ========== -->
<!-- Intentionally skipped in user-facing notes; key changes are included in 2.0.2. -->
<!-- ========== 1.8.0 ========== -->
<article class="version-block" data-version="1.8.0">
<div class="version-chip">1.8.0</div>