fix(analytics): prevent database locks during suspension

This commit is contained in:
Rocky
2026-08-21 21:13:13 +08:00
parent 7bd77509b6
commit ac374631ae
11 changed files with 599 additions and 155 deletions
@@ -1,97 +1,32 @@
// AnalyticsExtensionService.swift
// OSGKeyboard · Keyboard Extension
//
// The extension records into the shared SQLite queue and only attempts one
// short anonymous batch when Full Access permits network use.
// The extension only records into durable queues. The host app is the sole
// network uploader, avoiding extension-lifecycle and cross-process lock races.
import Foundation
import OSGKeyboardShared
import OSLog
private actor ExtensionAnalyticsUploadSignal: AnalyticsUploadTriggering {
typealias Action = @Sendable () async -> Void
private var action: Action?
private var canUpload = false
private var isUploading = false
func install(_ action: @escaping Action) {
self.action = action
}
func setCanUpload(_ canUpload: Bool) {
self.canUpload = canUpload
}
func requestUpload() {
guard canUpload, !isUploading, let action else { return }
isUploading = true
Task {
await action()
uploadFinished()
}
}
private func uploadFinished() {
isUploading = false
}
}
private struct ExtensionAnalyticsLogger: AnalyticsLogging {
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios.keyboard",
category: "analytics"
)
func log(_ entry: AnalyticsUploadLogEntry) {
let statusCode = entry.statusCode ?? 0
let errorCategory = entry.errorCategory?.rawValue ?? "none"
logger.info(
"outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)"
)
}
}
final class AnalyticsExtensionService: Sendable {
static let shared = AnalyticsExtensionService()
let client: any AnalyticsClient
private let runtime: AnalyticsRuntime
private let uploadSignal: ExtensionAnalyticsUploadSignal
private init() {
let signal = ExtensionAnalyticsUploadSignal()
uploadSignal = signal
let environment = Self.environment
let runtime = AnalyticsRuntime.keyboardExtension(
environment: Self.environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint),
trigger: signal,
logger: ExtensionAnalyticsLogger()
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
)
self.runtime = runtime
client = runtime.client
Task {
await signal.install {
await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1)
}
}
}
func recordPresentation(hasFullAccess: Bool) {
Task {
await uploadSignal.setCanUpload(hasFullAccess)
client.recordSessionActivity()
client.recordKeyboardActivated()
}
func recordPresentation(hasFullAccess _: Bool) {
client.recordSessionActivity()
client.recordKeyboardActivated()
}
func keyboardWillDisappear() {
Task {
await uploadSignal.setCanUpload(false)
}
}
func keyboardWillDisappear() {}
private static let endpoint = URL(
string: "https://account.osglab.com/v1/analytics/events"