chore: snapshot uncommitted local work before rebasing onto cloud-agent
Safety commit so the 94 uncommitted files in the working tree (in-flight Account/Mac/Tip work plus the feature-preview recording tooling) survive the move onto cursor/cloud-agent-1787824905651-vxuwc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -75,7 +75,30 @@ public struct MonthlyUsageCalendar: View {
|
||||
}
|
||||
|
||||
private var cellDiameter: CGFloat {
|
||||
#if os(macOS)
|
||||
// Mac 端收紧圆点直径,让左侧日历与右侧 2×2 网格高度接近。
|
||||
compact ? 26 : 28
|
||||
#else
|
||||
compact ? 28 : 32
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 日期网格的行间距(Mac 端更紧凑)。
|
||||
private var dayRowSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
compact ? 2 : 3
|
||||
#else
|
||||
compact ? 3 : 5
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 卡片内各区块(标题 / 星期 / 日期网格)的垂直间距。
|
||||
private var sectionSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
Spacing.xs
|
||||
#else
|
||||
Spacing.sm
|
||||
#endif
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
@@ -91,7 +114,7 @@ public struct MonthlyUsageCalendar: View {
|
||||
}
|
||||
|
||||
private var calendarContent: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
VStack(alignment: .leading, spacing: sectionSpacing) {
|
||||
header
|
||||
weekdayHeader
|
||||
dayGrid
|
||||
@@ -133,7 +156,7 @@ public struct MonthlyUsageCalendar: View {
|
||||
}
|
||||
|
||||
private var dayGrid: some View {
|
||||
LazyVGrid(columns: columns, spacing: compact ? 3 : 5) {
|
||||
LazyVGrid(columns: columns, spacing: dayRowSpacing) {
|
||||
ForEach(Array(calendarCells.enumerated()), id: \.offset) { _, point in
|
||||
if let point {
|
||||
dayCell(point)
|
||||
@@ -161,7 +184,7 @@ public struct MonthlyUsageCalendar: View {
|
||||
let usesContrastingText = opacity >= 0.48
|
||||
|
||||
return Text(day.formatted())
|
||||
.font(.system(size: compact ? 12 : 13, weight: isToday ? .semibold : .medium, design: .rounded))
|
||||
.font(.system(size: dayFontSize, weight: isToday ? .semibold : .medium, design: .rounded))
|
||||
.foregroundStyle(
|
||||
isFuture
|
||||
? palette.textTertiary.opacity(0.45)
|
||||
@@ -185,6 +208,14 @@ public struct MonthlyUsageCalendar: View {
|
||||
)
|
||||
}
|
||||
|
||||
private var dayFontSize: CGFloat {
|
||||
#if os(macOS)
|
||||
compact ? 11 : 12
|
||||
#else
|
||||
compact ? 12 : 13
|
||||
#endif
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
// SupportDeveloperSection.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Optional voluntary tip block for Settings. Does not gate features.
|
||||
|
||||
import SwiftUI
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
/// iOS Settings card for the consumable support tip.
|
||||
public struct SupportDeveloperSection: View {
|
||||
@ObservedObject private var tipManager: TipPurchaseManager
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private let language: AppUILanguage
|
||||
|
||||
@State private var showThankYouAlert = false
|
||||
@State private var showErrorAlert = false
|
||||
@State private var errorMessage = ""
|
||||
|
||||
public init(
|
||||
language: AppUILanguage,
|
||||
tipManager: TipPurchaseManager = .shared
|
||||
) {
|
||||
self.language = language
|
||||
self.tipManager = tipManager
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
CardSection(title: SharedL10n.string("tip.title", language: language)) {
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
SupportDeveloperTipBody(language: language)
|
||||
|
||||
if tipManager.supportCount > 0 {
|
||||
Text(
|
||||
SharedL10n.format(
|
||||
"tip.thankYou.past",
|
||||
language: language,
|
||||
tipManager.supportCount
|
||||
)
|
||||
)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.accent)
|
||||
}
|
||||
|
||||
tipButton
|
||||
.disabled(isPurchaseInFlight)
|
||||
|
||||
Text(SharedL10n.string("tip.consumableNotice", language: language))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.surfaceCard()
|
||||
}
|
||||
.onChange(of: tipManager.purchaseState) { _, newValue in
|
||||
switch newValue {
|
||||
case .succeeded:
|
||||
showThankYouAlert = true
|
||||
case .failed(let message):
|
||||
errorMessage = message
|
||||
showErrorAlert = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.thankYou.title", language: language),
|
||||
isPresented: $showThankYouAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
} message: {
|
||||
Text(SharedL10n.string("tip.thankYou.message", language: language))
|
||||
}
|
||||
.alert(
|
||||
SharedL10n.string("tip.error.title", language: language),
|
||||
isPresented: $showErrorAlert
|
||||
) {
|
||||
Button(SharedL10n.string("tip.alert.dismiss", language: language)) {
|
||||
tipManager.acknowledgePurchaseState()
|
||||
}
|
||||
} message: {
|
||||
Text(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private var isPurchaseInFlight: Bool {
|
||||
switch tipManager.purchaseState {
|
||||
case .loading, .purchasing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var tipButton: some View {
|
||||
Button {
|
||||
Task { await tipManager.purchase() }
|
||||
} label: {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if isPurchaseInFlight {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
Text(tipButtonTitle)
|
||||
.font(TypeStyle.body.weight(.semibold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.foregroundStyle(OSGColor.fixedLightContent)
|
||||
.background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(tipButtonTitle)
|
||||
}
|
||||
|
||||
private var tipButtonTitle: String {
|
||||
if let product = tipManager.product {
|
||||
return SharedL10n.format("tip.button", language: language, product.displayPrice)
|
||||
}
|
||||
return SharedL10n.string("tip.buttonFallback", language: language)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared copy + tip button for macOS Settings (Mac chrome wraps this).
|
||||
public struct SupportDeveloperTipBody: View {
|
||||
@Environment(\.themePalette) private var palette
|
||||
|
||||
private let language: AppUILanguage
|
||||
|
||||
public init(language: AppUILanguage) {
|
||||
self.language = language
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Text(SharedL10n.string("tip.body", language: language))
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
chartCard
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
splitStatGrid
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
@@ -121,6 +121,7 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
systemImage: "waveform"
|
||||
)
|
||||
}
|
||||
.frame(maxHeight: splitRowMaxHeight)
|
||||
HStack(spacing: splitItemSpacing) {
|
||||
splitCell(
|
||||
title: SharedL10n.string("stat.translation", language: language),
|
||||
@@ -136,6 +137,7 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
action: onOpenDictionary
|
||||
)
|
||||
}
|
||||
.frame(maxHeight: splitRowMaxHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +166,8 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
title: title,
|
||||
value: value,
|
||||
caption: caption,
|
||||
accent: accent
|
||||
accent: accent,
|
||||
expands: splitCellsFillHeight
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
disclosureIndicator
|
||||
@@ -173,15 +176,17 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(maxWidth: .infinity, maxHeight: splitRowMaxHeight)
|
||||
} else {
|
||||
UsageStatCard(
|
||||
title: title,
|
||||
value: value,
|
||||
caption: caption,
|
||||
systemImage: systemImage,
|
||||
accent: accent
|
||||
accent: accent,
|
||||
expands: splitCellsFillHeight
|
||||
)
|
||||
.frame(maxHeight: splitRowMaxHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +340,7 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
|
||||
private var splitSectionSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
Spacing.md
|
||||
Spacing.sm
|
||||
#else
|
||||
CardLayoutMetrics.sectionSpacing
|
||||
#endif
|
||||
@@ -343,11 +348,24 @@ public struct UsageStatsCluster<Header: View>: View {
|
||||
|
||||
private var splitItemSpacing: CGFloat {
|
||||
#if os(macOS)
|
||||
Spacing.md
|
||||
Spacing.sm
|
||||
#else
|
||||
CardLayoutMetrics.compactItemSpacing
|
||||
#endif
|
||||
}
|
||||
|
||||
private var splitRowMaxHeight: CGFloat? {
|
||||
splitCellsFillHeight ? .infinity : nil
|
||||
}
|
||||
|
||||
/// Mac 侧的 2×2 网格拉伸到与左侧日历同高,保证左右卡片底边对齐。
|
||||
private var splitCellsFillHeight: Bool {
|
||||
#if os(macOS)
|
||||
layout == .split
|
||||
#else
|
||||
false
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension UsageStatsCluster where Header == EmptyView {
|
||||
|
||||
@@ -80,7 +80,7 @@ public actor AccountAPIClient {
|
||||
private struct RefreshOperation {
|
||||
let id: UUID
|
||||
let failedAccessToken: String
|
||||
let task: Task<AccountSession, Error>
|
||||
let task: Task<AccountTokenSession, Error>
|
||||
}
|
||||
|
||||
private let baseURL: URL
|
||||
@@ -90,7 +90,7 @@ public actor AccountAPIClient {
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
private var cachedSession: AccountSession?
|
||||
private var cachedSession: AccountTokenSession?
|
||||
private var didLoadSession = false
|
||||
private var refreshOperation: RefreshOperation?
|
||||
private var invalidationContinuations: [
|
||||
@@ -113,19 +113,19 @@ public actor AccountAPIClient {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountSession {
|
||||
public func signInWithApple(_ request: AppleSignInRequest) async throws -> AccountTokenSession {
|
||||
let data = try await perform(
|
||||
endpoint: .appleSignIn,
|
||||
body: try encode(request),
|
||||
requiresSession: false
|
||||
)
|
||||
let session = try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
||||
let session = try decode(APIDataEnvelope<AccountTokenSession>.self, from: data).data
|
||||
try await replaceSession(with: session)
|
||||
try? await sessionVault.clearRefreshTransaction()
|
||||
return session
|
||||
}
|
||||
|
||||
public func currentSession() async throws -> AccountSession? {
|
||||
public func currentSession() async throws -> AccountTokenSession? {
|
||||
try await loadSessionIfNeeded()
|
||||
}
|
||||
|
||||
@@ -386,7 +386,7 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func sessionForRequest() async throws -> AccountSession {
|
||||
private func sessionForRequest() async throws -> AccountTokenSession {
|
||||
guard let session = try await loadSessionIfNeeded() else {
|
||||
throw AccountAPIError.sessionUnavailable
|
||||
}
|
||||
@@ -403,7 +403,7 @@ public actor AccountAPIClient {
|
||||
return session
|
||||
}
|
||||
|
||||
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountSession {
|
||||
private func refreshSession(afterUnauthorizedAccessToken failedToken: String) async throws -> AccountTokenSession {
|
||||
// Refresh always re-reads Keychain so another client instance cannot
|
||||
// rotate a stale in-memory session with a new operation identifier.
|
||||
guard let current = try await reloadSessionFromVault() else {
|
||||
@@ -438,7 +438,7 @@ public actor AccountAPIClient {
|
||||
return try await finishRefresh(operation)
|
||||
}
|
||||
|
||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountSession {
|
||||
private func finishRefresh(_ operation: RefreshOperation) async throws -> AccountTokenSession {
|
||||
do {
|
||||
let replacement = try await operation.task.value
|
||||
guard let current = try await reloadSessionFromVault() else {
|
||||
@@ -478,7 +478,7 @@ public actor AccountAPIClient {
|
||||
private func requestRefresh(
|
||||
using refreshToken: String,
|
||||
operationId: UUID
|
||||
) async throws -> AccountSession {
|
||||
) async throws -> AccountTokenSession {
|
||||
let request = try makeRequest(
|
||||
endpoint: .refresh,
|
||||
body: try encode(
|
||||
@@ -491,7 +491,7 @@ public actor AccountAPIClient {
|
||||
)
|
||||
let response = try await send(request)
|
||||
let data = try validatedData(response)
|
||||
return try decode(APIDataEnvelope<AccountSession>.self, from: data).data
|
||||
return try decode(APIDataEnvelope<AccountTokenSession>.self, from: data).data
|
||||
}
|
||||
|
||||
private func shouldClearSession(afterRefreshError error: Error) -> Bool {
|
||||
@@ -504,7 +504,7 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadSessionIfNeeded() async throws -> AccountSession? {
|
||||
private func loadSessionIfNeeded() async throws -> AccountTokenSession? {
|
||||
if didLoadSession {
|
||||
return cachedSession
|
||||
}
|
||||
@@ -517,7 +517,7 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func reloadSessionFromVault() async throws -> AccountSession? {
|
||||
private func reloadSessionFromVault() async throws -> AccountTokenSession? {
|
||||
do {
|
||||
let session = try await sessionVault.loadSession()
|
||||
cachedSession = session
|
||||
@@ -528,7 +528,7 @@ public actor AccountAPIClient {
|
||||
}
|
||||
}
|
||||
|
||||
private func replaceSession(with session: AccountSession) async throws {
|
||||
private func replaceSession(with session: AccountTokenSession) async throws {
|
||||
do {
|
||||
try await sessionVault.saveSession(session)
|
||||
cachedSession = session
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AccountSession: Codable, Equatable, Sendable {
|
||||
public struct AccountTokenSession: Codable, Equatable, Sendable {
|
||||
public let accountId: UUID
|
||||
public let tokenType: String
|
||||
public let accessToken: String
|
||||
@@ -229,8 +229,8 @@ public struct AccountRefreshTransaction: Codable, Equatable, Sendable {
|
||||
}
|
||||
|
||||
public protocol AccountSessionVault: Sendable {
|
||||
func loadSession() async throws -> AccountSession?
|
||||
func saveSession(_ session: AccountSession) async throws
|
||||
func loadSession() async throws -> AccountTokenSession?
|
||||
func saveSession(_ session: AccountTokenSession) async throws
|
||||
func clearSession() async throws
|
||||
func beginRefreshTransaction(
|
||||
refreshTokenDigest: String
|
||||
|
||||
@@ -10,6 +10,10 @@ import Security
|
||||
public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
public static let defaultService = "com.osgkeyboard.ios.account"
|
||||
public static let hostBundleIdentifier = "com.osgkeyboard.ios"
|
||||
/// The macOS menu-bar app has no extension to share with, so it owns its
|
||||
/// own private group. Listed here so the guard below still rejects the
|
||||
/// App-Group-reachable `com.osgkeyboard.shared` group on both platforms.
|
||||
public static let macHostBundleIdentifier = "com.osgkeyboard.mac"
|
||||
|
||||
public let service: String
|
||||
public let accessGroup: String
|
||||
@@ -20,8 +24,10 @@ public struct HostPrivateAccountKeychainDescriptor: Equatable, Sendable {
|
||||
) throws {
|
||||
let normalizedService = service.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedAccessGroup = accessGroup.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let isHostPrivate = [Self.hostBundleIdentifier, Self.macHostBundleIdentifier]
|
||||
.contains { normalizedAccessGroup.hasSuffix(".\($0)") }
|
||||
guard !normalizedService.isEmpty,
|
||||
normalizedAccessGroup.hasSuffix(".\(Self.hostBundleIdentifier)"),
|
||||
isHostPrivate,
|
||||
!normalizedAccessGroup.hasSuffix(".com.osgkeyboard.shared") else {
|
||||
throw AccountAPIError.secureStorage
|
||||
}
|
||||
@@ -67,9 +73,9 @@ public actor HostPrivateAccountKeychain:
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
public func loadSession() async throws -> AccountSession? {
|
||||
public func loadSession() async throws -> AccountTokenSession? {
|
||||
do {
|
||||
let session = try read(AccountSession.self, account: Account.session)
|
||||
let session = try read(AccountTokenSession.self, account: Account.session)
|
||||
Self.logger.info(
|
||||
"session keychain restore status=\(session == nil ? "not-found" : "found", privacy: .public)"
|
||||
)
|
||||
@@ -80,7 +86,7 @@ public actor HostPrivateAccountKeychain:
|
||||
}
|
||||
}
|
||||
|
||||
public func saveSession(_ session: AccountSession) async throws {
|
||||
public func saveSession(_ session: AccountTokenSession) async throws {
|
||||
try write(session, account: Account.session)
|
||||
}
|
||||
|
||||
@@ -205,12 +211,20 @@ public actor HostPrivateAccountKeychain:
|
||||
}
|
||||
|
||||
private func baseQuery(account: String) -> [String: Any] {
|
||||
[
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: descriptor.service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccessGroup as String: descriptor.accessGroup,
|
||||
kSecAttrSynchronizable as String: kCFBooleanFalse!
|
||||
]
|
||||
#if os(macOS)
|
||||
// macOS defaults to the legacy file-based keychain, which ignores
|
||||
// `kSecAttrAccessGroup` and rejects `kSecAttrAccessible`. Opt into the
|
||||
// data-protection keychain so entitlement-scoped access groups and the
|
||||
// AfterFirstUnlockThisDeviceOnly protection behave as they do on iOS.
|
||||
query[kSecUseDataProtectionKeychain as String] = true
|
||||
#endif
|
||||
return query
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Provisions anonymous, App-Attest-bound OOBE credentials without creating or
|
||||
// mutating an AccountSession.
|
||||
// mutating an AccountTokenSession.
|
||||
|
||||
import Foundation
|
||||
#if canImport(OSGKeyboardShared)
|
||||
|
||||
@@ -150,7 +150,7 @@ public actor AccountSignInCoordinator {
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func signIn() async throws -> AccountSession {
|
||||
public func signIn() async throws -> AccountTokenSession {
|
||||
let nonce = try nonceGenerator.makeNonce()
|
||||
let credential = try await appleAuthorization.authorize(nonceSHA256: nonce.sha256Hex)
|
||||
let evidence = try await integrity.evidenceForAppleSignIn(
|
||||
|
||||
@@ -98,7 +98,11 @@ private final class FlowPrerollStore: @unchecked Sendable {
|
||||
}
|
||||
|
||||
/// Rolling bar levels updated from the audio tap; read on the main actor.
|
||||
private final class FlowLevelStore: @unchecked Sendable {
|
||||
///
|
||||
/// Deliberately not `private`: the bar arithmetic runs on the realtime audio
|
||||
/// thread where a bad range is an uncatchable trap, so it needs direct
|
||||
/// `@testable` coverage rather than only being reachable through a live engine.
|
||||
final class FlowLevelStore: @unchecked Sendable {
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var levels: [Float]
|
||||
|
||||
@@ -115,7 +119,7 @@ private final class FlowLevelStore: @unchecked Sendable {
|
||||
lock.withLock { levels }
|
||||
}
|
||||
|
||||
private static func calculateLevels(from buffer: AVAudioPCMBuffer, barCount: Int) -> [Float] {
|
||||
static func calculateLevels(from buffer: AVAudioPCMBuffer, barCount: Int) -> [Float] {
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
return Array(repeating: 0, count: barCount)
|
||||
}
|
||||
@@ -127,7 +131,12 @@ private final class FlowLevelStore: @unchecked Sendable {
|
||||
var result = [Float]()
|
||||
result.reserveCapacity(barCount)
|
||||
for barIndex in 0..<barCount {
|
||||
let start = barIndex * samplesPerBar
|
||||
// A tap buffer shorter than `barCount` floors `samplesPerBar` to 1,
|
||||
// so the tail bars would otherwise start past `frameLength` and
|
||||
// build a reversed `start..<end` range — an uncatchable trap on the
|
||||
// realtime audio thread. Clamping `start` leaves those bars empty
|
||||
// (level 0), which is the right reading for samples we do not have.
|
||||
let start = min(barIndex * samplesPerBar, frameLength)
|
||||
let end = min(start + samplesPerBar, frameLength)
|
||||
var sum: Float = 0
|
||||
for i in start..<end {
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
// TipPurchaseManager.swift
|
||||
// OSGKeyboard · HostSupport
|
||||
//
|
||||
// Optional ¥30 consumable tip via StoreKit 2. Voluntary support only —
|
||||
// no feature gates, no App Group sync, no restore (Apple consumable rules).
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import StoreKit
|
||||
#if canImport(OSGKeyboardShared)
|
||||
import OSGKeyboardShared
|
||||
#endif
|
||||
|
||||
public enum TipPurchaseState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case purchasing
|
||||
case succeeded
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public final class TipPurchaseManager: ObservableObject {
|
||||
public static let shared = TipPurchaseManager()
|
||||
|
||||
@Published public private(set) var product: Product?
|
||||
@Published public private(set) var purchaseState: TipPurchaseState = .idle
|
||||
@Published public private(set) var supportCount: Int
|
||||
|
||||
private var transactionUpdatesTask: Task<Void, Never>?
|
||||
|
||||
public init(defaults: UserDefaults = .standard) {
|
||||
supportCount = defaults.integer(forKey: TipProduct.supportCountDefaultsKey)
|
||||
transactionUpdatesTask = Task { [weak self] in
|
||||
await self?.listenForTransactionUpdates()
|
||||
}
|
||||
Task { await loadProducts() }
|
||||
}
|
||||
|
||||
deinit {
|
||||
transactionUpdatesTask?.cancel()
|
||||
}
|
||||
|
||||
/// Loads the tip product from the App Store / StoreKit Test configuration.
|
||||
public func loadProducts() async {
|
||||
guard purchaseState != .purchasing else { return }
|
||||
purchaseState = .loading
|
||||
do {
|
||||
let products = try await Product.products(for: TipProduct.allProductIDs)
|
||||
product = products.first(where: { $0.id == TipProduct.supportID })
|
||||
purchaseState = product == nil
|
||||
? .failed(SharedL10n.string("tip.error.productUnavailable"))
|
||||
: .idle
|
||||
} catch {
|
||||
product = nil
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the StoreKit purchase sheet for the support tip.
|
||||
public func purchase() async {
|
||||
guard let product else {
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.productUnavailable"))
|
||||
return
|
||||
}
|
||||
purchaseState = .purchasing
|
||||
do {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try Self.verified(verification)
|
||||
await handleCompletedTip(transaction)
|
||||
purchaseState = .succeeded
|
||||
case .userCancelled:
|
||||
purchaseState = .idle
|
||||
case .pending:
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.pending"))
|
||||
@unknown default:
|
||||
purchaseState = .failed(SharedL10n.string("tip.error.unknown"))
|
||||
}
|
||||
} catch {
|
||||
purchaseState = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears transient success / failure state after the UI acknowledges it.
|
||||
public func acknowledgePurchaseState() {
|
||||
switch purchaseState {
|
||||
case .succeeded, .failed:
|
||||
purchaseState = .idle
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func listenForTransactionUpdates() async {
|
||||
for await update in Transaction.updates {
|
||||
guard let transaction = try? Self.verified(update),
|
||||
transaction.productID == TipProduct.supportID else { continue }
|
||||
await handleCompletedTip(transaction)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleCompletedTip(_ transaction: Transaction) async {
|
||||
await transaction.finish()
|
||||
recordSupportPurchase()
|
||||
}
|
||||
|
||||
private func recordSupportPurchase() {
|
||||
supportCount += 1
|
||||
UserDefaults.standard.set(supportCount, forKey: TipProduct.supportCountDefaultsKey)
|
||||
}
|
||||
|
||||
private static func verified<T>(_ result: VerificationResult<T>) throws -> T {
|
||||
switch result {
|
||||
case .verified(let value):
|
||||
return value
|
||||
case .unverified:
|
||||
throw TipPurchaseError.failedVerification
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum TipPurchaseError: Error {
|
||||
case failedVerification
|
||||
}
|
||||
Reference in New Issue
Block a user