Files
OSGKeyboard/OSGKeyboardShared/Services/UsageStatisticsStore.swift
T
Rocky cc8dd1070a feat: macOS architecture, cloud ASR/LLM providers, and 6-step iOS onboarding
- Add macOS menu-bar dictation app with local ASR models (SenseVoice/Qwen3),
  global Option hotkey, and bottom overlay
- Add cloud ASR/LLM providers (Anthropic, Volcengine, Bailian, and more) with
  provider logos, model listing, and connection checks
- Add shared 7-day usage stats UI (UsageStatsCluster / SevenDayUsageChart)
- Add iOS onboarding step 6 for polish LLM setup; hide custom-language-model
  diagnostic toggle behind DEBUG
- Unify iOS onboarding tagline with the macOS brand line ("开口即文字。")
- Rewrite README (Chinese-first, product-oriented) and refresh GitHub Pages
2026-07-11 19:10:20 +08:00

168 lines
7.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// UsageStatisticsStore.swift
// OSGKeyboard · Shared
//
// Observable store for cumulative usage metrics. Updated after each
// successful dictation on iOS Flow and macOS menu-bar capture.
import Combine
import Foundation
@MainActor
public final class UsageStatisticsStore: ObservableObject {
public static let shared = UsageStatisticsStore()
@Published public private(set) var dictationDurationSeconds: TimeInterval = 0
@Published public private(set) var dictationCharacterCount: Int = 0
@Published public private(set) var translationCharacterCount: Int = 0
/// Cross-device dictation characters per local day (`yyyy-MM-dd`), used by
/// the home page's 7-day chart.
@Published public private(set) var dailyDictationCharacters: [String: Int] = [:]
/// How many days of daily buckets to retain on disk. Well beyond the 7-day
/// chart window so a device that syncs in late still contributes recent days.
private static let dailyRetentionDays = 90
public let defaults: UserDefaults
/// Marks the one-time purge of statistics corrupted by the pre-fix
/// double-counting bug (see `purgeCorruptedStatsIfNeeded`).
private static let dirtyResetFlagKey = "usageStatistics.dirtyReset.v1"
public init(defaults: UserDefaults? = nil) {
self.defaults = defaults ?? AppGroupStore().defaults
purgeCorruptedStatsIfNeeded()
reloadFromDisk()
NotificationCenter.default.addObserver(
forName: .usageStatisticsDidSyncFromCloud,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.reloadFromDisk()
}
}
}
public func recordUtterance(text: String, duration: TimeInterval, wasTranslation: Bool) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let count = Self.characterCount(for: trimmed)
// Increment ONLY this device's own slice. The displayed totals are the
// cross-device *sum* (see `reloadFromDisk`), so incrementing in-memory
// display state and writing it back as this device's slice would fold
// every other device's total into this one and double-count on the
// next reload — the bug that inflated one slice to ~8× the real usage.
let deviceID = SyncDeviceID.current(defaults: defaults)
var slice = SyncedUsageStatisticsStorage.currentDeviceSlice(from: defaults, deviceID: deviceID)
if wasTranslation {
slice.translationCharacterCount += count
} else {
slice.dictationCharacterCount += count
let dayKey = UsageStatisticsDayKey.key(for: Date())
slice.dailyDictationCharacters[dayKey, default: 0] += count
UsageStatisticsDayKey.prune(&slice.dailyDictationCharacters, keepingDays: Self.dailyRetentionDays)
}
slice.dictationDurationSeconds += max(0, duration)
slice.updatedAt = Date()
SyncedUsageStatisticsStorage.upsertCurrentDeviceSlice(slice, defaults: defaults, deviceID: deviceID)
reloadFromDisk()
Task {
try? await UsageStatisticsCloudSync.shared.pushLocalIfEnabled()
}
}
/// Refreshes the published totals from disk. Display-only: it reads the
/// aggregated cross-device sum and NEVER writes it back (writing would
/// corrupt the per-device slices — see `recordUtterance`).
public func reloadFromDisk() {
let payload = SyncedUsageStatisticsStorage.load(from: defaults)
let aggregated = payload.aggregated
dictationDurationSeconds = aggregated.dictationDurationSeconds
dictationCharacterCount = aggregated.dictationCharacterCount
translationCharacterCount = aggregated.translationCharacterCount
dailyDictationCharacters = payload.aggregatedDailyDictationCharacters
}
// MARK: - 7-day chart data
/// One day's dictation total for the home page chart.
public struct DailyUsagePoint: Identifiable, Equatable, Sendable {
public let date: Date
public let value: Int
public var id: Date { date }
}
/// The trailing 7 local days (oldest → newest), zero-filled for days with no
/// dictation, so the chart always renders a full week.
public var last7Days: [DailyUsagePoint] {
Self.last7Days(from: dailyDictationCharacters)
}
public static func last7Days(
from daily: [String: Int],
now: Date = Date(),
calendar: Calendar = .current
) -> [DailyUsagePoint] {
let startOfToday = calendar.startOfDay(for: now)
var points: [DailyUsagePoint] = []
for offset in stride(from: 6, through: 0, by: -1) {
guard let day = calendar.date(byAdding: .day, value: -offset, to: startOfToday) else { continue }
let key = UsageStatisticsDayKey.key(for: day, calendar: calendar)
points.append(DailyUsagePoint(date: day, value: daily[key] ?? 0))
}
return points
}
/// One-time cleanup: the pre-fix code overwrote a device slice with the
/// cross-device *sum*, so every reload/record re-added the other devices'
/// totals and one slice ballooned to ~8× the true usage. We can't recover
/// the true per-device split from corrupted data, so wipe local + remote
/// once and let the corrected per-device accounting re-accumulate cleanly.
private func purgeCorruptedStatsIfNeeded() {
guard !defaults.bool(forKey: Self.dirtyResetFlagKey) else { return }
defaults.set(true, forKey: Self.dirtyResetFlagKey)
defaults.removeObject(forKey: SyncedUsageStatisticsStorage.storageKey)
defaults.removeObject(forKey: UsageStatisticsStorage.storageKey)
defaults.removeObject(forKey: UsageStatisticsStorage.legacyMacTotalWordsKey)
UsageStatisticsCloudSync.shared.purgeRemote()
}
public static func characterCount(for text: String) -> Int {
text.trimmingCharacters(in: .whitespacesAndNewlines).count
}
// MARK: - Formatting
public static func formatDuration(_ seconds: TimeInterval, language: AppUILanguage) -> String {
let total = max(0, Int(seconds.rounded()))
if total < 60 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(total)秒"
: "\(total)s"
}
let hours = total / 3600
let minutes = (total % 3600) / 60
if hours > 0 {
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(hours)小时\(minutes)分"
: "\(hours)h \(minutes)m"
}
return language.resolvedLanguageCode().hasPrefix("zh")
? "\(minutes)分"
: "\(minutes)m"
}
public static func formatCount(_ value: Int, language: AppUILanguage) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.locale = Locale(identifier: language.resolvedLanguageCode())
return formatter.string(from: NSNumber(value: value)) ?? "\(value)"
}
}