mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
1502 lines
66 KiB
Swift
1502 lines
66 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
//
|
|
// stringlint:disable
|
|
|
|
import UIKit
|
|
import CryptoKit
|
|
import Combine
|
|
import GRDB
|
|
|
|
#if DEBUG
|
|
import Darwin
|
|
#endif
|
|
|
|
// MARK: - Singleton
|
|
|
|
public extension Singleton {
|
|
static let storage: SingletonConfig<Storage> = Dependencies.create(
|
|
identifier: "storage",
|
|
createInstance: { dependencies in Storage(using: dependencies) }
|
|
)
|
|
static let scheduler: SingletonConfig<ValueObservationScheduler> = Dependencies.create(
|
|
identifier: "scheduler",
|
|
createInstance: { _ in AsyncValueObservationScheduler.async(onQueue: .main) }
|
|
)
|
|
}
|
|
|
|
// MARK: - Log.Category
|
|
|
|
public extension Log.Category {
|
|
static let storage: Log.Category = .create("Storage", defaultLevel: .info)
|
|
}
|
|
|
|
// MARK: - KeychainStorage
|
|
|
|
public extension KeychainStorage.DataKey { static let dbCipherKeySpec: Self = "GRDBDatabaseCipherKeySpec" }
|
|
|
|
// MARK: - Storage
|
|
|
|
open class Storage {
|
|
public static let base32: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
public static let queuePrefix: String = "SessionDatabase"
|
|
public static let dbFileName: String = "Session.sqlite"
|
|
private static let SQLCipherKeySpecLength: Int = 48
|
|
|
|
/// If a transaction takes longer than this duration a warning will be logged but the transaction will continue to run
|
|
private static let slowTransactionThreshold: TimeInterval = 3
|
|
|
|
/// When attempting to do a write the transaction will wait this long to acquite a lock before failing
|
|
private static let writeTransactionStartTimeout: TimeInterval = 5
|
|
|
|
/// If a transaction takes longer than this duration then we should fail the transaction rather than keep hanging
|
|
///
|
|
/// **Note:** This timeout only applies to synchronous operations (the assumption being that if we know an operation is going to
|
|
/// take a long time then we should probably be handling it asynchronously rather than a synchronous way)
|
|
private static let transactionDeadlockTimeoutSeconds: Int = 5
|
|
|
|
public static var sharedDatabaseDirectoryPath: String { "\(SessionFileManager.nonInjectedAppSharedDataDirectoryPath)/database" }
|
|
private static var databasePath: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)" }
|
|
private static var databasePathShm: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-shm" }
|
|
private static var databasePathWal: String { "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)-wal" }
|
|
|
|
private let id: String = (0..<5).map { _ in "\(base32.randomElement() ?? "0")" }.joined()
|
|
private let dependencies: Dependencies
|
|
fileprivate var dbWriter: DatabaseWriter?
|
|
internal var testDbWriter: DatabaseWriter? { dbWriter }
|
|
|
|
// MARK: - Database State Variables
|
|
|
|
private var startupError: Error?
|
|
@ThreadSafe public private(set) var isValid: Bool = false
|
|
@ThreadSafe public private(set) var isSuspended: Bool = false
|
|
public var isDatabasePasswordAccessible: Bool { ((try? getDatabaseCipherKeySpec()) != nil) }
|
|
public private(set) var hasCompletedMigrations: Bool = false
|
|
|
|
/// This property gets set the first time we successfully read from the database
|
|
@ThreadSafe public private(set) var hasSuccessfullyRead: Bool = false
|
|
|
|
/// This property gets set the first time we successfully write to the database
|
|
@ThreadSafe public private(set) var hasSuccessfullyWritten: Bool = false
|
|
|
|
/// This property keeps track of all current database calls and can be used when suspending the database to explicitly
|
|
/// cancel any currently running tasks
|
|
@ThreadSafeObject private var currentCalls: Set<CallInfo> = []
|
|
|
|
/// This property keeps track of all current database observers for logging purposes
|
|
@ThreadSafeObject private var currentObservers: Set<ObserverInfo> = []
|
|
|
|
// MARK: - Initialization
|
|
|
|
public init(customWriter: DatabaseWriter? = nil, using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
|
|
configureDatabase(customWriter: customWriter)
|
|
}
|
|
|
|
public init(
|
|
testAccessTo databasePath: String,
|
|
encryptedKeyPath: String,
|
|
encryptedKeyPassword: String,
|
|
using dependencies: Dependencies
|
|
) throws {
|
|
self.dependencies = dependencies
|
|
|
|
try testAccess(
|
|
databasePath: databasePath,
|
|
encryptedKeyPath: encryptedKeyPath,
|
|
encryptedKeyPassword: encryptedKeyPassword
|
|
)
|
|
}
|
|
|
|
public func configureDatabase(customWriter: DatabaseWriter? = nil) {
|
|
/// If we have verbose logging enabled then retrieve and output the size of the database files
|
|
if dependencies[feature: .logLevel(cat: .storage)] == .verbose {
|
|
let dbFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePath)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
let dbShmFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePathShm)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
let dbWalFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePathWal)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
Log.verbose(.storage, "Configuring new database instance: \(id) (db: \(dbFileSize), shm: \(dbShmFileSize), wal: \(dbWalFileSize)).")
|
|
}
|
|
|
|
/// Create the database directory if needed and ensure it's protection level is set before attempting to create the database
|
|
/// KeySpec or the database itself
|
|
try? dependencies[singleton: .fileManager].ensureDirectoryExists(at: Storage.sharedDatabaseDirectoryPath)
|
|
try? dependencies[singleton: .fileManager].protectFileOrFolder(at: Storage.sharedDatabaseDirectoryPath)
|
|
|
|
// If a custom writer was provided then use that (for unit testing)
|
|
guard customWriter == nil else {
|
|
dbWriter = customWriter
|
|
isValid = true
|
|
return
|
|
}
|
|
|
|
/// Generate the database KeySpec if needed (this MUST be done before we try to access the database as a different thread
|
|
/// might attempt to access the database before the key is successfully created)
|
|
///
|
|
/// We reset the bytes immediately after generation to ensure the database key doesn't hang around in memory unintentionally
|
|
///
|
|
/// **Note:** If we fail to get/generate the keySpec then don't bother continuing to setup the Database as it'll just be invalid,
|
|
/// in this case the App/Extensions will have logic that checks the `isValid` flag of the database
|
|
do {
|
|
var tmpKeySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey(
|
|
forKey: .dbCipherKeySpec,
|
|
length: Storage.SQLCipherKeySpecLength,
|
|
cat: .storage,
|
|
legacyKey: "GRDBDatabaseCipherKeySpec",
|
|
legacyService: "TSKeyChainService"
|
|
)
|
|
tmpKeySpec.resetBytes(in: 0..<tmpKeySpec.count)
|
|
}
|
|
catch { return }
|
|
|
|
// Configure the database and create the DatabasePool for interacting with the database
|
|
var config = Configuration()
|
|
config.label = Storage.queuePrefix
|
|
config.maximumReaderCount = 10 /// Increase the max read connection limit - Default is 5
|
|
config.busyMode = .timeout(Storage.writeTransactionStartTimeout)
|
|
|
|
/// Load in the SQLCipher keys
|
|
config.prepareDatabase { [weak self] db in
|
|
var keySpec: Data = try self?.dependencies[singleton: .keychain].getOrGenerateEncryptionKey(
|
|
forKey: .dbCipherKeySpec,
|
|
length: Storage.SQLCipherKeySpecLength,
|
|
cat: .storage,
|
|
legacyKey: "GRDBDatabaseCipherKeySpec",
|
|
legacyService: "TSKeyChainService"
|
|
) ?? { throw KeychainStorageError.keySpecInvalid }()
|
|
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
|
|
|
// Use a raw key spec, where the 96 hexadecimal digits are provided
|
|
// (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt)
|
|
// using explicit BLOB syntax, e.g.:
|
|
//
|
|
// x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101'
|
|
keySpec = try (keySpec.toHexString().data(using: .utf8) ?? {
|
|
throw KeychainStorageError.keySpecInvalid
|
|
}())
|
|
keySpec.insert(contentsOf: [120, 39], at: 0) // "x'" prefix
|
|
keySpec.append(39) // "'" suffix
|
|
|
|
try db.usePassphrase(keySpec)
|
|
|
|
// According to the SQLCipher docs iOS needs the 'cipher_plaintext_header_size' value set to at least
|
|
// 32 as iOS extends special privileges to the database and needs this header to be in plaintext
|
|
// to determine the file type
|
|
//
|
|
// For more info see: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_plaintext_header_size
|
|
try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
|
|
}
|
|
|
|
// Create the DatabasePool to allow us to connect to the database and mark the storage as valid
|
|
do {
|
|
do {
|
|
dbWriter = try DatabasePool(
|
|
path: "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)",
|
|
configuration: config
|
|
)
|
|
}
|
|
catch DatabaseError.SQLITE_BUSY {
|
|
/// According to the docs in GRDB there are a few edge-cases where opening the database
|
|
/// can fail due to it reporting a "busy" state, by changing the behaviour from `immediateError`
|
|
/// to `timeout(1)` we give the database a 1 second grace period to deal with it's issues
|
|
/// and get back into a valid state - adding this helps the database resolve situations where it
|
|
/// can get confused due to crashing mid-transaction
|
|
config.busyMode = .timeout(1)
|
|
Log.warn(.storage, "Database reported busy state during startup, adding grace period to allow startup to continue")
|
|
|
|
// Try to initialise the dbWriter again (hoping the above resolves the lock)
|
|
dbWriter = try DatabasePool(
|
|
path: "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)",
|
|
configuration: config
|
|
)
|
|
}
|
|
catch let error as DatabaseError where error.resultCode == .SQLITE_CANTOPEN {
|
|
/// We were seeing some cases where the PN extension could get an error where it coudln't open the
|
|
/// database but based on the logs all previous queires and everything had completed, so if this happens
|
|
/// we want to wait for a brief period and try again in case it was due to something weird the OS was
|
|
/// doing with the files
|
|
Log.warn(.storage, "Database reported that it couldn't open during startup (\(error.extendedResultCode)), retrying after a short delay")
|
|
Thread.sleep(forTimeInterval: 1)
|
|
|
|
// Try to initialise the dbWriter again (hoping the above resolves the lock)
|
|
dbWriter = try DatabasePool(
|
|
path: "\(Storage.sharedDatabaseDirectoryPath)/\(Storage.dbFileName)",
|
|
configuration: config
|
|
)
|
|
}
|
|
catch let error as DatabaseError where error.resultCode == .SQLITE_IOERR {
|
|
Log.error(.storage, "Database reported that it couldn't open during startup (\(error.extendedResultCode))")
|
|
throw error
|
|
}
|
|
isValid = true
|
|
}
|
|
catch { startupError = error }
|
|
}
|
|
|
|
// MARK: - Migrations
|
|
|
|
public static func appliedMigrationIdentifiers(_ db: ObservingDatabase) -> Set<String> {
|
|
let migrator: DatabaseMigrator = DatabaseMigrator()
|
|
|
|
return (try? migrator.appliedIdentifiers(db.originalDb))
|
|
.defaulting(to: [])
|
|
}
|
|
|
|
public func perform(
|
|
migrations: [Migration.Type],
|
|
async: Bool = true,
|
|
onProgressUpdate: ((CGFloat, TimeInterval) -> ())?,
|
|
onComplete: @escaping (Result<Void, Error>) -> ()
|
|
) {
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
|
let error: Error = (startupError ?? StorageError.startupFailed)
|
|
Log.error(.storage, "Startup failed with error: \(error)")
|
|
onComplete(.failure(error))
|
|
return
|
|
}
|
|
|
|
// Setup and run any required migrations
|
|
var migrator: DatabaseMigrator = DatabaseMigrator()
|
|
migrations.forEach { migration in
|
|
migrator.registerMigration(migration.identifier) { [dependencies] db in
|
|
let migration = migration.loggedMigrate(using: dependencies)
|
|
try migration(ObservingDatabase.create(db, using: dependencies))
|
|
}
|
|
}
|
|
|
|
// Determine which migrations need to be performed and gather the relevant settings needed to
|
|
// inform the app of progress/states
|
|
let completedMigrations: [String] = (try? dbWriter.read { db in try migrator.completedMigrations(db) })
|
|
.defaulting(to: [])
|
|
let unperformedMigrations: [Migration.Type] = migrations
|
|
.reduce(into: []) { result, next in
|
|
guard !completedMigrations.contains(next.identifier) else { return }
|
|
|
|
result.append(next)
|
|
}
|
|
let migrationToDurationMap: [String: TimeInterval] = unperformedMigrations
|
|
.reduce(into: [:]) { result, next in
|
|
result[next.identifier] = next.minExpectedRunDuration
|
|
}
|
|
let unperformedMigrationDurations: [TimeInterval] = unperformedMigrations.map { $0.minExpectedRunDuration }
|
|
let totalMinExpectedDuration: TimeInterval = migrationToDurationMap.values.reduce(0, +)
|
|
|
|
// Store the logic to handle migration progress and completion
|
|
let progressUpdater: (String, CGFloat) -> Void = { (targetKey: String, progress: CGFloat) in
|
|
guard let migrationIndex: Int = unperformedMigrations.firstIndex(where: { $0.identifier == targetKey }) else {
|
|
return
|
|
}
|
|
|
|
let completedExpectedDuration: TimeInterval = (
|
|
(migrationIndex > 0 ? unperformedMigrationDurations[0..<migrationIndex].reduce(0, +) : 0) +
|
|
(unperformedMigrationDurations[migrationIndex] * progress)
|
|
)
|
|
let totalProgress: CGFloat = (completedExpectedDuration / totalMinExpectedDuration)
|
|
|
|
DispatchQueue.main.async {
|
|
onProgressUpdate?(totalProgress, totalMinExpectedDuration)
|
|
}
|
|
}
|
|
let migrationCompleted: (Result<Void, Error>) -> () = { [weak self, migrator, dbWriter, dependencies] result in
|
|
// Make sure to transition the progress updater to 100% for the final migration (just
|
|
// in case the migration itself didn't update to 100% itself)
|
|
if let lastMigrationIdentifier: String = unperformedMigrations.last?.identifier {
|
|
MigrationExecution.current?.progressUpdater(lastMigrationIdentifier, 1)
|
|
}
|
|
|
|
self?.hasCompletedMigrations = true
|
|
|
|
// Output any events tracked during the migration and trigger any `postCommitActions` which
|
|
// should occur
|
|
if let events: [ObservedEvent] = MigrationExecution.current?.observedEvents {
|
|
dependencies.notifyAsync(events: events)
|
|
}
|
|
|
|
if let actions: [String: () -> Void] = MigrationExecution.current?.postCommitActions {
|
|
actions.values.forEach { $0() }
|
|
}
|
|
|
|
// Don't log anything in the case of a 'success' or if the database is suspended (the
|
|
// latter will happen if the user happens to return to the background too quickly on
|
|
// launch so is unnecessarily alarming, it also gets caught and logged separately by
|
|
// the 'write' functions anyway)
|
|
switch result {
|
|
case .success: break
|
|
case .failure(DatabaseError.SQLITE_ABORT): break
|
|
case .failure(let error):
|
|
let completedMigrations: [String] = (try? dbWriter
|
|
.read { db in try migrator.completedMigrations(db) })
|
|
.defaulting(to: [])
|
|
let failedMigrationName: String = migrator.migrations
|
|
.filter { !completedMigrations.contains($0) }
|
|
.first
|
|
.defaulting(to: "Unknown")
|
|
Log.critical(.migration, "Migration '\(failedMigrationName)' failed with error: \(error)")
|
|
}
|
|
|
|
onComplete(result)
|
|
}
|
|
|
|
// if there aren't any migrations to run then just complete immediately (this way the migrator
|
|
// doesn't try to execute on the DBWrite thread so returning from the background can't get blocked
|
|
// due to some weird endless process running)
|
|
guard !unperformedMigrations.isEmpty else {
|
|
migrationCompleted(.success(()))
|
|
return
|
|
}
|
|
|
|
// Create the `MigrationContext`
|
|
let migrationContext: MigrationExecution.Context = MigrationExecution.Context(progressUpdater: progressUpdater)
|
|
|
|
// If we have an unperformed migration then trigger the progress updater immediately
|
|
if let firstMigrationIdentifier: String = unperformedMigrations.first?.identifier {
|
|
migrationContext.progressUpdater(firstMigrationIdentifier, 0)
|
|
}
|
|
|
|
MigrationExecution.$current.withValue(migrationContext) {
|
|
// Note: The non-async migration should only be used for unit tests
|
|
guard async else { return migrationCompleted(Result(catching: { try migrator.migrate(dbWriter) })) }
|
|
|
|
migrator.asyncMigrate(dbWriter) { [dependencies] result in
|
|
let finalResult: Result<Void, Error> = {
|
|
switch result {
|
|
case .failure(let error): return .failure(error)
|
|
case .success: return .success(())
|
|
}
|
|
}()
|
|
|
|
// Note: We need to dispatch this to the next run toop to prevent blocking if the callback
|
|
// performs subsequent database operations
|
|
DispatchQueue.global(qos: .userInitiated).async(using: dependencies) {
|
|
migrationCompleted(finalResult)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Security
|
|
|
|
private func getDatabaseCipherKeySpec() throws -> Data {
|
|
try dependencies[singleton: .keychain].migrateLegacyKeyIfNeeded(
|
|
legacyKey: "GRDBDatabaseCipherKeySpec",
|
|
legacyService: "TSKeyChainService",
|
|
toKey: .dbCipherKeySpec
|
|
)
|
|
return try dependencies[singleton: .keychain].data(forKey: .dbCipherKeySpec)
|
|
}
|
|
|
|
// MARK: - File Management
|
|
|
|
/// In order to avoid the `0xdead10cc` exception we manually track whether database access should be suspended, when
|
|
/// in a suspended state this class will fail/reject all read/write calls made to it. Additionally if there was an existing transaction
|
|
/// in progress it will be interrupted.
|
|
///
|
|
/// The generally suggested approach is to avoid this entirely by not storing the database in an AppGroup folder and sharing it
|
|
/// with extensions - this may be possible but will require significant refactoring and a potentially painful migration to move the
|
|
/// database and other files into the App folder
|
|
public func suspendDatabaseAccess() {
|
|
guard !isSuspended else { return }
|
|
|
|
isSuspended = true
|
|
Log.info(
|
|
.storage,
|
|
[
|
|
"Database access suspended - ",
|
|
"cancelling \(currentCalls.count) running task(s)\(currentCalls.isEmpty ? "" : " (\(currentCalls.map(\.id).joined(separator: ", ")))"), ",
|
|
"\(currentObservers.count) active observers(s)\(currentObservers.isEmpty ? "" : " (\(currentObservers.map(\.id).joined(separator: ", ")))"), checkpointing and closing connection."
|
|
].joined()
|
|
)
|
|
|
|
/// Instruct GRDB to release as much memory as it can (non-blocking)
|
|
(dbWriter as? DatabasePool)?.releaseMemoryEventually()
|
|
|
|
/// Before triggering an `interrupt` (which will forcibly kill in-progress database queries) we want to try to cancel all
|
|
/// database tasks to give them a small chance to resolve cleanly before we take a brute-force approach
|
|
currentCalls.forEach { $0.cancel() }
|
|
_currentCalls.performUpdate { _ in [] }
|
|
|
|
/// We want to force a checkpoint (ie. write any data in the WAL to disk, to ensure the main database is in a valid state)
|
|
do { try checkpoint(.truncate) }
|
|
catch { Log.info(.storage, "Failed to checkpoint database due to error: \(error)") }
|
|
|
|
/// Interrupt any open transactions (if this function is called then we are expecting that all processes have finished running
|
|
/// and don't actually want any more transactions to occur)
|
|
dbWriter?.interrupt()
|
|
|
|
/// If we have verbose logging enabled then retrieve and output the size of the database files
|
|
if dependencies[feature: .logLevel(cat: .storage)] == .verbose {
|
|
let dbFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePath)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
let dbShmFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePathShm)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
let dbWalFileSize: String = (try? dependencies[singleton: .fileManager]
|
|
.attributesOfItem(atPath: Storage.databasePathWal)
|
|
.getting(.size) as? Int64)
|
|
.map { ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) }
|
|
.defaulting(to: "N/A")
|
|
Log.verbose(.storage, "Database suspended successfully for \(id) (db: \(dbFileSize), shm: \(dbShmFileSize), wal: \(dbWalFileSize)).")
|
|
}
|
|
|
|
dependencies.notifyAsync(key: .databaseLifecycle(.suspended))
|
|
}
|
|
|
|
/// This method reverses the database suspension used to prevent the `0xdead10cc` exception (see `suspendDatabaseAccess()`
|
|
/// above for more information
|
|
public func resumeDatabaseAccess() {
|
|
guard isSuspended else { return }
|
|
|
|
isSuspended = false
|
|
Log.info(.storage, "Database access resumed.")
|
|
dependencies.notifyAsync(key: .databaseLifecycle(.resumed))
|
|
}
|
|
|
|
public func checkpoint(_ mode: Database.CheckpointMode) throws {
|
|
try dbWriter?.writeWithoutTransaction { db in _ = try db.checkpoint(mode) }
|
|
}
|
|
|
|
public func closeDatabase() throws {
|
|
suspendDatabaseAccess()
|
|
isValid = false
|
|
dbWriter = nil
|
|
}
|
|
|
|
public func resetAllStorage() {
|
|
isValid = false
|
|
hasCompletedMigrations = false
|
|
dbWriter = nil
|
|
|
|
deleteDatabaseFiles()
|
|
do { try deleteDbKeys() } catch { Log.warn(.storage, "Failed to delete database keys.") }
|
|
}
|
|
|
|
public func reconfigureDatabase() {
|
|
configureDatabase()
|
|
}
|
|
|
|
public func resetForCleanMigration() {
|
|
// Clear existing content
|
|
resetAllStorage()
|
|
|
|
// Reconfigure
|
|
reconfigureDatabase()
|
|
}
|
|
|
|
private func deleteDatabaseFiles() {
|
|
do { try dependencies[singleton: .fileManager].removeItem(atPath: Storage.databasePath) }
|
|
catch { Log.warn(.storage, "Failed to delete database.") }
|
|
do { try dependencies[singleton: .fileManager].removeItem(atPath: Storage.databasePathShm) }
|
|
catch { Log.warn(.storage, "Failed to delete database-shm.") }
|
|
do { try dependencies[singleton: .fileManager].removeItem(atPath: Storage.databasePathWal) }
|
|
catch { Log.warn(.storage, "Failed to delete database-wal.") }
|
|
}
|
|
|
|
private func deleteDbKeys() throws {
|
|
try dependencies[singleton: .keychain].remove(key: .dbCipherKeySpec)
|
|
}
|
|
|
|
// MARK: - Logging Functions
|
|
|
|
enum StorageState {
|
|
case valid(DatabaseWriter)
|
|
case invalid(Error)
|
|
|
|
var forcedError: Error {
|
|
switch self {
|
|
case .valid: return StorageError.validStorageIncorrectlyHandledAsError
|
|
case .invalid(let error): return error
|
|
}
|
|
}
|
|
|
|
init(_ storage: Storage?) {
|
|
switch (storage?.isSuspended, storage?.isValid, storage?.dbWriter) {
|
|
case (true, _, _): self = .invalid(StorageError.databaseSuspended)
|
|
case (false, true, .some(let dbWriter)): self = .valid(dbWriter)
|
|
default: self = .invalid(StorageError.databaseInvalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Operations
|
|
|
|
/// Internal type to wrap the result for the `performOperation` so that its assignment is Sendable
|
|
private final class ResultContainer<T> {
|
|
var value: Result<T, Error>?
|
|
|
|
init(value: Result<T, Error>? = nil) {
|
|
self.value = value
|
|
}
|
|
}
|
|
|
|
/// This function manually performs `read`/`write` operations via Swift Concurrency and should only be called via the appropriate
|
|
/// `perform{x}Operation`functions to reduce duplication
|
|
private static func performOperation<T>(
|
|
_ info: CallInfo,
|
|
_ dbWriter: DatabaseWriter,
|
|
_ operation: @escaping (ObservingDatabase) throws -> T,
|
|
_ dependencies: Dependencies
|
|
) async -> Result<T, Error> {
|
|
typealias DatabaseOutput = (
|
|
result: T,
|
|
events: [ObservedEvent],
|
|
postCommitActions: [() -> Void]
|
|
)
|
|
typealias DatabaseResult = (result: T, postCommitActions: [() -> Void])
|
|
|
|
return await withThrowingTaskGroup(of: DatabaseResult.self) { group in
|
|
/// Add the task to perform the actual database operation
|
|
group.addTask {
|
|
let trackedOperation: @Sendable (Database) throws -> DatabaseOutput = { db in
|
|
info.start()
|
|
guard info.storage?.isValid == true else { throw StorageError.databaseInvalid }
|
|
guard info.storage?.isSuspended == false else {
|
|
throw StorageError.databaseSuspended
|
|
}
|
|
|
|
/// Create the `ObservingDatabase` and store it in the `ObservationContext` so objects can access
|
|
/// it while the operation is running (this allows us to use things like `aroundInsert` without having to resort
|
|
/// to hacks to give it access to the `ObservingDatabase` or `Dependencies` instances
|
|
let observingDatabase: ObservingDatabase = ObservingDatabase.create(db, using: dependencies)
|
|
let result: T = try ObservationContext.$observingDb.withValue(observingDatabase) {
|
|
try operation(observingDatabase)
|
|
}
|
|
|
|
/// Update the state flags
|
|
switch info.isWrite {
|
|
case true: info.storage?.hasSuccessfullyWritten = true
|
|
case false: info.storage?.hasSuccessfullyRead = true
|
|
}
|
|
|
|
return (
|
|
result,
|
|
observingDatabase.events,
|
|
Array(observingDatabase.postCommitActions.values)
|
|
)
|
|
}
|
|
|
|
/// Do this outside of the actually db operation as it's more for debugging queries running on the main thread
|
|
/// than trying to slow the query itself
|
|
if !SNUtilitiesKit.isRunningTests && dependencies[feature: .forceSlowDatabaseQueries] {
|
|
try await Task.sleep(for: .seconds(1))
|
|
}
|
|
|
|
let output: DatabaseOutput = (info.isWrite ?
|
|
try await dbWriter.write(trackedOperation) :
|
|
try await dbWriter.read(trackedOperation)
|
|
)
|
|
|
|
/// Trigger the observations
|
|
dependencies.notifyAsync(events: output.events)
|
|
|
|
return (output.result, output.postCommitActions)
|
|
}
|
|
|
|
/// If this is a syncronous task then we want to the operation to timeout to ensure we don't unintentionally
|
|
/// create a deadlock
|
|
if !info.isAsync {
|
|
group.addTask {
|
|
/// If the debugger is attached then we want to have a lot of shorter sleep iterations as the clock doesn't get
|
|
/// paused when stopped on a breakpoint (and we don't want to end up having a bunch of false positive
|
|
/// database timeouts while debugging code)
|
|
///
|
|
/// **Note:** `isDebuggerAttached` will always return `false` in production builds
|
|
if isDebuggerAttached() {
|
|
let numIterations: UInt64 = 50
|
|
|
|
for _ in (0..<numIterations) {
|
|
try await Task.sleep(for: .seconds(Storage.transactionDeadlockTimeoutSeconds))
|
|
}
|
|
}
|
|
else if info.isWrite {
|
|
/// This if statement is redundant **but** it means when we get symbolicated crash logs we can distinguish
|
|
/// between the database threads which are reading and writing
|
|
try await Task.sleep(for: .seconds(Storage.transactionDeadlockTimeoutSeconds))
|
|
}
|
|
else {
|
|
try await Task.sleep(for: .seconds(Storage.transactionDeadlockTimeoutSeconds))
|
|
}
|
|
throw StorageError.transactionDeadlockTimeout
|
|
}
|
|
}
|
|
|
|
/// Wait for the first task to finish
|
|
///
|
|
/// **Note:** The case where `nextResult` returns `nil` is only meant to happen when the group has no
|
|
/// tasks, so shouldn't be considered a valid case (hence the `invalidQueryResult` fallback)
|
|
let output: Result<DatabaseResult, Error> = await (
|
|
group.nextResult() ??
|
|
.failure(StorageError.invalidQueryResult)
|
|
)
|
|
group.cancelAll()
|
|
|
|
/// If the database operation completed successfully we should trigger any of the `postCommitActions`
|
|
switch output {
|
|
case .failure: break
|
|
case .success(let result): result.postCommitActions.forEach { $0() }
|
|
}
|
|
|
|
/// Return the actual result
|
|
return output.map { $0.result }
|
|
}
|
|
}
|
|
|
|
/// This function manually performs `read`/`write` operations in either a synchronous or asyncronous way using a semaphore to
|
|
/// block the syncrhonous version because `GRDB` has an internal assertion when using it's built-in synchronous `read`/`write`
|
|
/// functions to prevent reentrancy which is unsupported
|
|
///
|
|
/// The `async` variants don't need to worry about this reentrancy issue so instead we route we use those for all operations instead
|
|
/// and just block the thread when we want to perform a synchronous operation
|
|
///
|
|
/// **Note:** When running a synchronous operation the result will be returned and `asyncCompletion` will not be called, and
|
|
/// vice-versa for an asynchronous operation
|
|
@discardableResult private func performOperation<T>(
|
|
_ info: CallInfo,
|
|
_ dependencies: Dependencies,
|
|
_ operation: @escaping (ObservingDatabase) throws -> T,
|
|
_ asyncCompletion: ((Result<T, Error>) -> Void)? = nil
|
|
) -> Result<T, Error> {
|
|
/// Ensure we are in a valid state
|
|
let storageState: StorageState = StorageState(info.storage)
|
|
|
|
guard case .valid(let dbWriter) = storageState else {
|
|
info.errored(storageState.forcedError)
|
|
return .failure(storageState.forcedError)
|
|
}
|
|
|
|
/// Setup required variables
|
|
let semaphore: DispatchSemaphore? = (info.isAsync ? nil : DispatchSemaphore(value: 0))
|
|
let syncResultContainer: ResultContainer<T>? = (info.isAsync ? nil : ResultContainer())
|
|
|
|
/// Log that we are scheduling the operation (so we have a log in case it's blocked for some reason)
|
|
info.schedule()
|
|
|
|
/// Kick off and store the task in case we want to cancel it later
|
|
info.task = Task {
|
|
info.storage?.addCall(info)
|
|
defer { info.storage?.removeCall(info) }
|
|
|
|
let result = await Storage.performOperation(info, dbWriter, operation, dependencies)
|
|
|
|
/// Log the result
|
|
switch result {
|
|
case .success: info.complete()
|
|
case .failure(let error): info.errored(error)
|
|
}
|
|
|
|
/// Now that we have completed the database operation we don't need to track the task anymore so we can
|
|
/// remove it
|
|
///
|
|
/// **Note:** we want to remove it before `asyncCompletion` is called just in case that is a long running
|
|
/// process
|
|
info.storage?.removeCall(info)
|
|
|
|
/// Send the result back
|
|
switch info.isAsync {
|
|
case true: asyncCompletion?(result)
|
|
case false:
|
|
syncResultContainer?.value = result
|
|
semaphore?.signal()
|
|
}
|
|
}
|
|
|
|
/// For the `async` operation the returned value should be ignored so just return the `invalidQueryResult` error
|
|
if info.behaviour == .asyncRead || info.behaviour == .asyncWrite{
|
|
return .failure(StorageError.invalidQueryResult)
|
|
}
|
|
|
|
/// Block until we have a result
|
|
semaphore?.wait()
|
|
return (syncResultContainer?.value ?? .failure(StorageError.transactionDeadlockTimeout))
|
|
}
|
|
|
|
private func performPublisherOperation<T>(
|
|
_ fileName: String,
|
|
_ functionName: String,
|
|
_ lineNumber: Int,
|
|
isWrite: Bool,
|
|
_ operation: @escaping (ObservingDatabase) throws -> T
|
|
) -> AnyPublisher<T, Error> {
|
|
let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, (isWrite ? .asyncWrite : .asyncRead))
|
|
|
|
switch StorageState(self) {
|
|
case .invalid(let error): return info.errored(error)
|
|
case .valid:
|
|
/// Log that we are scheduling the operation (so we have a log in case it's blocked for some reason)
|
|
info.schedule()
|
|
|
|
/// **Note:** GRDB does have `readPublisher`/`writePublisher` functions but they appear to asynchronously
|
|
/// trigger both the `output` and `complete` closures at the same time which causes a lot of unexpected
|
|
/// behaviours (this behaviour is apparently expected but still causes a number of odd behaviours in our code
|
|
/// for more information see https://github.com/groue/GRDB.swift/issues/1334)
|
|
///
|
|
/// Instead of this we are just using `Deferred { PassthroughSubject }` which is executed on the specified
|
|
/// scheduler (which behaves in a much more expected way than the GRDB `readPublisher`/`writePublisher`
|
|
/// does) and hooking that into our `performOperation` function which uses the GRDB async/await functions that
|
|
/// support cancellation (as we want to support cancellation as well)
|
|
return Deferred { [dependencies] in
|
|
let subject = PassthroughSubject<T, Error>()
|
|
let bridge: PublisherBridge<T> = PublisherBridge(subject: subject)
|
|
|
|
/// Kick off and store the task in case we want to cancel it later
|
|
info.task = Task {
|
|
info.storage?.addCall(info)
|
|
defer { info.storage?.removeCall(info) }
|
|
|
|
/// Ensure we are in a valid state
|
|
let storageState: StorageState = StorageState(info.storage)
|
|
|
|
guard case .valid(let dbWriter) = storageState else {
|
|
await bridge.send(result: .failure(storageState.forcedError), info: info)
|
|
return
|
|
}
|
|
|
|
let result = await Storage.performOperation(info, dbWriter, operation, dependencies)
|
|
|
|
if Task.isCancelled {
|
|
return
|
|
}
|
|
|
|
await bridge.send(result: result, info: info)
|
|
}
|
|
|
|
return subject.handleEvents(receiveCancel: { [weak self, weak info] in
|
|
info?.cancel()
|
|
self?.removeCall(info)
|
|
Task { await bridge.cancel() }
|
|
})
|
|
}
|
|
.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
private func performSwiftConcurrencyOperation<T>(
|
|
_ fileName: String,
|
|
_ functionName: String,
|
|
_ lineNumber: Int,
|
|
isWrite: Bool,
|
|
_ operation: @escaping (ObservingDatabase) throws -> T
|
|
) async throws -> T {
|
|
let info: CallInfo = CallInfo(self, fileName, functionName, lineNumber, (isWrite ? .swiftConcurrencyWrite : .swiftConcurrencyRead))
|
|
let storageState: StorageState = StorageState(self)
|
|
|
|
guard case .valid(let dbWriter) = storageState else {
|
|
info.errored(storageState.forcedError)
|
|
throw storageState.forcedError
|
|
}
|
|
|
|
info.schedule()
|
|
addCall(info)
|
|
defer { removeCall(info) }
|
|
|
|
return try await Storage.performOperation(info, dbWriter, operation, dependencies)
|
|
.successOrThrow()
|
|
}
|
|
|
|
private func addCall(_ call: CallInfo) {
|
|
_currentCalls.performUpdate { $0.inserting(call) }
|
|
}
|
|
|
|
private func removeCall(_ call: CallInfo?) {
|
|
guard let call: CallInfo = call else { return }
|
|
|
|
_currentCalls.performUpdate { $0.removing(call) }
|
|
}
|
|
|
|
private func addObserver(_ observer: ObserverInfo) {
|
|
_currentObservers.performUpdate { $0.inserting(observer) }
|
|
}
|
|
|
|
private func removeObserver(_ observer: ObserverInfo) {
|
|
_currentObservers.performUpdate { $0.removing(observer) }
|
|
}
|
|
|
|
private func stopAndRemoveObserver(forId id: String, explicitRemoval: Bool) {
|
|
_currentObservers.performUpdate {
|
|
$0.filter { info -> Bool in
|
|
guard info.id == id else { return true }
|
|
|
|
info.stop(explicitRemoval: explicitRemoval)
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
@discardableResult public func write<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
updates: @escaping (ObservingDatabase) throws -> T?
|
|
) -> T? {
|
|
switch performOperation(CallInfo(self, file, funcN, line, .syncWrite), dependencies, updates) {
|
|
case .failure: return nil
|
|
case .success(let result): return result
|
|
}
|
|
}
|
|
|
|
open func writeAsync<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
updates: @escaping (ObservingDatabase) throws -> T,
|
|
completion: @escaping (Result<T, Error>) -> Void = { _ in }
|
|
) {
|
|
performOperation(CallInfo(self, file, funcN, line, .asyncWrite), dependencies, updates, completion)
|
|
}
|
|
|
|
@discardableResult public func writeAsync<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
updates: @escaping (ObservingDatabase) throws -> T
|
|
) async throws -> T {
|
|
return try await performSwiftConcurrencyOperation(file, funcN, line, isWrite: true, updates)
|
|
}
|
|
|
|
open func writePublisher<T>(
|
|
fileName: String = #fileID,
|
|
functionName: String = #function,
|
|
lineNumber: Int = #line,
|
|
updates: @escaping (ObservingDatabase) throws -> T
|
|
) -> AnyPublisher<T, Error> {
|
|
return performPublisherOperation(fileName, functionName, lineNumber, isWrite: true, updates)
|
|
}
|
|
|
|
@discardableResult public func read<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
_ value: @escaping (ObservingDatabase) throws -> T?
|
|
) -> T? {
|
|
switch performOperation(CallInfo(self, file, funcN, line, .syncRead), dependencies, value) {
|
|
case .failure: return nil
|
|
case .success(let result): return result
|
|
}
|
|
}
|
|
|
|
public func readAsync<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
retrieve: @escaping (ObservingDatabase) throws -> T,
|
|
completion: @escaping (Result<T, Error>) -> Void
|
|
) {
|
|
performOperation(CallInfo(self, file, funcN, line, .asyncRead), dependencies, retrieve, completion)
|
|
}
|
|
|
|
@discardableResult public func readAsync<T>(
|
|
fileName file: String = #fileID,
|
|
functionName funcN: String = #function,
|
|
lineNumber line: Int = #line,
|
|
value: @escaping (ObservingDatabase) throws -> T
|
|
) async throws -> T {
|
|
return try await performSwiftConcurrencyOperation(file, funcN, line, isWrite: false, value)
|
|
}
|
|
|
|
open func readPublisher<T>(
|
|
fileName: String = #fileID,
|
|
functionName: String = #function,
|
|
lineNumber: Int = #line,
|
|
value: @escaping (ObservingDatabase) throws -> T
|
|
) -> AnyPublisher<T, Error> {
|
|
return performPublisherOperation(fileName, functionName, lineNumber, isWrite: false, value)
|
|
}
|
|
|
|
/// Rever to the `ValueObservation.start` method for full documentation
|
|
///
|
|
/// - parameter observation: The observation to start
|
|
/// - parameter scheduler: A Scheduler. By default, fresh values are
|
|
/// dispatched asynchronously on the main queue.
|
|
/// - parameter onError: A closure that is provided eventual errors that
|
|
/// happen during observation
|
|
/// - parameter onChange: A closure that is provided fresh values
|
|
/// - returns: a DatabaseCancellable
|
|
public func start<Reducer: ValueReducer>(
|
|
_ observation: ValueObservation<Reducer>,
|
|
fileName: String = #fileID,
|
|
functionName: String = #function,
|
|
lineNumber: Int = #line,
|
|
scheduling scheduler: ValueObservationScheduler = .async(onQueue: .main),
|
|
onError: @escaping (Error) -> Void,
|
|
onChange: @escaping (Reducer.Value) -> Void
|
|
) -> DatabaseCancellable {
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
|
onError(StorageError.databaseInvalid)
|
|
return AnyDatabaseCancellable(cancel: {})
|
|
}
|
|
|
|
let info: ObserverInfo = ObserverInfo(self, fileName, functionName, lineNumber)
|
|
addObserver(info)
|
|
|
|
let cancellable: AnyDatabaseCancellable = observation
|
|
.handleEvents(didCancel: { [weak self] in
|
|
info.stop(explicitRemoval: false)
|
|
self?.removeObserver(info)
|
|
})
|
|
.start(
|
|
in: dbWriter,
|
|
scheduling: scheduler,
|
|
onError: onError,
|
|
onChange: onChange
|
|
)
|
|
info.setObservation(cancellable)
|
|
|
|
return cancellable
|
|
}
|
|
|
|
/// Add a database observation
|
|
///
|
|
/// **Note:** This function **MUST NOT** be called from the main thread
|
|
public func addObserver(
|
|
fileName: String = #fileID,
|
|
functionName: String = #function,
|
|
lineNumber: Int = #line,
|
|
_ observer: IdentifiableTransactionObserver?
|
|
) {
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
|
guard let observer: IdentifiableTransactionObserver = observer else { return }
|
|
|
|
let info: ObserverInfo = ObserverInfo(self, observer: observer, fileName, functionName, lineNumber)
|
|
addObserver(info)
|
|
|
|
/// This actually triggers a write to the database so can be blocked by other writes so shouldn't be called on the main thread,
|
|
/// we don't dispatch to an async thread in here because `TransactionObserver` isn't `Sendable` so instead just require
|
|
/// that it isn't called on the main thread
|
|
Log.assertNotOnMainThread()
|
|
dbWriter.add(transactionObserver: observer)
|
|
}
|
|
|
|
/// Remove a database observation
|
|
///
|
|
/// **Note:** This function **MUST NOT** be called from the main thread
|
|
public func removeObserver(
|
|
fileName: String = #fileID,
|
|
functionName: String = #function,
|
|
lineNumber: Int = #line,
|
|
_ observer: IdentifiableTransactionObserver?
|
|
) {
|
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
|
guard let observer: IdentifiableTransactionObserver = observer else { return }
|
|
|
|
stopAndRemoveObserver(forId: observer.id, explicitRemoval: true)
|
|
|
|
/// This actually triggers a write to the database so can be blocked by other writes so shouldn't be called on the main thread,
|
|
/// we don't dispatch to an async thread in here because `TransactionObserver` isn't `Sendable` so instead just require
|
|
/// that it isn't called on the main thread
|
|
Log.assertNotOnMainThread()
|
|
dbWriter.remove(transactionObserver: observer)
|
|
}
|
|
}
|
|
|
|
// MARK: - Combine Extensions
|
|
|
|
public extension ValueObservation {
|
|
func publisher(
|
|
in storage: Storage,
|
|
scheduling scheduler: ValueObservationScheduler
|
|
) -> AnyPublisher<Reducer.Value, Error> where Reducer: ValueReducer {
|
|
guard storage.isValid, let dbWriter: DatabaseWriter = storage.dbWriter else {
|
|
return Fail(error: StorageError.databaseInvalid).eraseToAnyPublisher()
|
|
}
|
|
|
|
return self.publisher(in: dbWriter, scheduling: scheduler)
|
|
.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
public extension Publisher where Failure == Error {
|
|
func flatMapStorageWritePublisher<T>(using dependencies: Dependencies, updates: @escaping (ObservingDatabase, Output) throws -> T) -> AnyPublisher<T, Error> {
|
|
return self.flatMap { output -> AnyPublisher<T, Error> in
|
|
dependencies[singleton: .storage].writePublisher(updates: { db in try updates(db, output) })
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
|
|
func flatMapStorageReadPublisher<T>(using dependencies: Dependencies, value: @escaping (ObservingDatabase, Output) throws -> T) -> AnyPublisher<T, Error> {
|
|
return self.flatMap { output -> AnyPublisher<T, Error> in
|
|
dependencies[singleton: .storage].readPublisher(value: { db in try value(db, output) })
|
|
}.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
|
|
// MARK: - CallInfo
|
|
|
|
private extension Storage {
|
|
private static let timerQueue = DispatchQueue(
|
|
label: "\(Storage.queuePrefix)-.transactionTimer",
|
|
qos: .background
|
|
)
|
|
|
|
class CallInfo: Hashable {
|
|
enum Behaviour {
|
|
case syncRead
|
|
case asyncRead
|
|
case syncWrite
|
|
case asyncWrite
|
|
case swiftConcurrencyRead
|
|
case swiftConcurrencyWrite
|
|
}
|
|
|
|
private enum Event {
|
|
case scheduled
|
|
case started
|
|
case detectedSlowQuery
|
|
case completed
|
|
case cancelled
|
|
case errored(Error)
|
|
|
|
var error: Error? {
|
|
switch self {
|
|
case .errored(let error): return error
|
|
default: return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
weak var storage: Storage?
|
|
private let uniqueId: UUID = UUID()
|
|
let id: String = (0..<4).map { _ in "\(base32.randomElement() ?? "0")" }.joined()
|
|
let file: String
|
|
let function: String
|
|
let line: Int
|
|
let behaviour: Behaviour
|
|
var task: Task<(), Never>?
|
|
|
|
private var timer: DispatchSourceTimer?
|
|
@ThreadSafe private var startTime: CFTimeInterval?
|
|
private(set) var wasSlowTransaction: Bool = false
|
|
|
|
var isWrite: Bool {
|
|
switch behaviour {
|
|
case .syncWrite, .asyncWrite, .swiftConcurrencyWrite: return true
|
|
case .syncRead, .asyncRead, .swiftConcurrencyRead: return false
|
|
}
|
|
}
|
|
var isAsync: Bool {
|
|
switch behaviour {
|
|
case .asyncRead, .asyncWrite, .swiftConcurrencyWrite: return true
|
|
case .syncRead, .syncWrite, .swiftConcurrencyRead: return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
_ storage: Storage?,
|
|
_ file: String,
|
|
_ function: String,
|
|
_ line: Int,
|
|
_ behaviour: Behaviour
|
|
) {
|
|
self.storage = storage
|
|
self.file = file
|
|
self.function = function
|
|
self.line = line
|
|
self.behaviour = behaviour
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
private func log(_ event: Event) {
|
|
let fileInfo: String = (file.components(separatedBy: "/").last.map { "\($0):\(line) - " } ?? "")
|
|
let action: String = "\(isWrite ? "write" : "read") query \(id)"
|
|
let callInfo: String = "\(fileInfo)\(function)"
|
|
let end: CFTimeInterval = CACurrentMediaTime()
|
|
let durationInfo: String = startTime
|
|
.map { start in "after \(end - start, format: ".2", omitZeroDecimal: true)s" }
|
|
.defaulting(to: "before it started")
|
|
let databaseName: String = ((storage?.id)
|
|
.map { "database \($0)" })
|
|
.defaulting(to: "the database")
|
|
|
|
switch (event, wasSlowTransaction, event.error) {
|
|
case (.scheduled, _, _):
|
|
Log.verbose(.storage, "Scheduling \(action) - [ \(callInfo) ]")
|
|
|
|
case (.started, _, _):
|
|
Log.verbose(.storage, "Started \(action) - [ \(callInfo) ]")
|
|
|
|
case (.detectedSlowQuery, _, _):
|
|
Log.warn(.storage, "Slow \(action) taking longer than \(Storage.slowTransactionThreshold, format: ".2", omitZeroDecimal: true)s - [ \(callInfo) ]")
|
|
|
|
case (.completed, true, _):
|
|
Log.warn(.storage, "Completed slow \(action) \(durationInfo) - [ \(callInfo) ]")
|
|
|
|
case (.completed, false, _):
|
|
Log.verbose(.storage, "Completed \(action) \(durationInfo) - [ \(callInfo) ]")
|
|
|
|
case (.cancelled, _, _):
|
|
Log.verbose(.storage, "Cancelled \(action) \(durationInfo) - [ \(callInfo) ]")
|
|
|
|
case (.errored(_ as CancellationError), _, _):
|
|
Log.verbose(.storage, "Cancelled \(action) \(durationInfo) - [ \(callInfo) ]")
|
|
|
|
case (.errored(let error as DatabaseError), _, .some(DatabaseError.SQLITE_ABORT)),
|
|
(.errored(let error as DatabaseError), _, .some(DatabaseError.SQLITE_INTERRUPT)),
|
|
(.errored(let error as DatabaseError), _, .some(DatabaseError.SQLITE_ERROR)):
|
|
Log.error(.storage, "Failed \(action) due to error: \(error) (\(error.extendedResultCode)")
|
|
|
|
case (.errored, _, .some(StorageError.databaseInvalid)):
|
|
let message: String = (storage?.startupError.map { "\($0)" } ?? "Unknown cause")
|
|
Log.error(.storage, "Failed \(action) as \(databaseName) is invalid (\(message)) - [ \(callInfo) ]")
|
|
|
|
case (.errored, _, .some(StorageError.databaseSuspended)):
|
|
Log.error(.storage, "Failed \(action) as \(databaseName) is suspended - [ \(callInfo) ]")
|
|
|
|
case (.errored, _, .some(StorageError.transactionDeadlockTimeout)):
|
|
Log.error(.storage, "Failed \(action) due to a potential synchronous query deadlock timeout - [ \(callInfo) ]")
|
|
|
|
case (.errored(let error), _, _):
|
|
Log.verbose(.storage, "Failed \(action) due to error: \(error) - [ \(callInfo) ]")
|
|
}
|
|
}
|
|
|
|
func schedule() {
|
|
log(.scheduled)
|
|
}
|
|
|
|
func start() {
|
|
log(.started)
|
|
startTime = CACurrentMediaTime()
|
|
timer = DispatchSource.makeTimerSource(queue: Storage.timerQueue)
|
|
timer?.schedule(
|
|
deadline: .now() + .seconds(Int(Storage.slowTransactionThreshold)),
|
|
repeating: .infinity // Infinity to fire once
|
|
)
|
|
timer?.setEventHandler { [weak self] in
|
|
self?.timer?.cancel()
|
|
self?.timer = nil
|
|
self?.wasSlowTransaction = true
|
|
self?.log(.detectedSlowQuery)
|
|
}
|
|
timer?.resume()
|
|
}
|
|
|
|
func complete() {
|
|
log(.completed)
|
|
timer?.cancel()
|
|
timer = nil
|
|
}
|
|
|
|
func errored(_ error: Error) {
|
|
log(.errored(error))
|
|
timer?.cancel()
|
|
timer = nil
|
|
}
|
|
|
|
func errored<T>(_ error: Error) -> AnyPublisher<T, Error> {
|
|
errored(error)
|
|
return Fail<T, Error>(error: error).eraseToAnyPublisher()
|
|
}
|
|
|
|
func cancel() {
|
|
task?.cancel()
|
|
timer?.cancel()
|
|
timer = nil
|
|
}
|
|
|
|
// MARK: - Conformance
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
uniqueId.hash(into: &hasher)
|
|
}
|
|
|
|
static func ==(lhs: CallInfo, rhs: CallInfo) -> Bool {
|
|
return lhs.uniqueId == rhs.uniqueId
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ObserverInfo
|
|
|
|
private extension Storage {
|
|
class ObserverInfo: Hashable {
|
|
private let uniqueId: UUID = UUID()
|
|
let id: String
|
|
let file: String
|
|
let function: String
|
|
let line: Int
|
|
|
|
private weak var storage: Storage?
|
|
private weak var observer: IdentifiableTransactionObserver?
|
|
private weak var cancellable: AnyDatabaseCancellable?
|
|
|
|
private var callInfo: String {
|
|
let fileInfo: String = (file.components(separatedBy: "/").last.map { "\($0):\(line) - " } ?? "")
|
|
|
|
return "\(fileInfo)\(function)"
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
_ storage: Storage?,
|
|
observer: IdentifiableTransactionObserver? = nil,
|
|
_ file: String,
|
|
_ function: String,
|
|
_ line: Int
|
|
) {
|
|
self.id = (observer?.id ?? (0..<4).map { _ in "\(base32.randomElement() ?? "0")" }.joined())
|
|
self.storage = storage
|
|
self.file = file
|
|
self.function = function
|
|
self.line = line
|
|
self.observer = observer
|
|
}
|
|
|
|
// MARK: - Functions
|
|
|
|
func setObservation(_ cancellable: AnyDatabaseCancellable) {
|
|
self.cancellable = cancellable
|
|
}
|
|
|
|
func start() {
|
|
Log.verbose(.storage, "Started observer \(id) - [ \(callInfo) ]")
|
|
}
|
|
|
|
func stop(explicitRemoval: Bool) {
|
|
guard cancellable != nil || observer != nil else { return }
|
|
|
|
cancellable?.cancel()
|
|
cancellable = nil
|
|
|
|
if let observer: IdentifiableTransactionObserver = observer, !explicitRemoval {
|
|
/// Need to set to `nil` first to prevent infinite loop
|
|
self.observer = nil
|
|
storage?.removeObserver(observer)
|
|
}
|
|
|
|
Log.verbose(.storage, "Stopped observer \(id) - [ \(callInfo) ]")
|
|
}
|
|
|
|
func cancel() {
|
|
guard cancellable != nil || observer != nil else { return }
|
|
|
|
cancellable?.cancel()
|
|
cancellable = nil
|
|
|
|
if let observer: IdentifiableTransactionObserver = observer {
|
|
/// Need to set to `nil` first to prevent infinite loop
|
|
self.observer = nil
|
|
storage?.removeObserver(observer)
|
|
}
|
|
|
|
Log.verbose(.storage, "Cancelled observer \(id) - [ \(callInfo) ]")
|
|
}
|
|
|
|
// MARK: - Conformance
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
uniqueId.hash(into: &hasher)
|
|
}
|
|
|
|
static func ==(lhs: ObserverInfo, rhs: ObserverInfo) -> Bool {
|
|
return lhs.uniqueId == rhs.uniqueId
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - IdentifiedTransactionObserver
|
|
|
|
public protocol IdentifiableTransactionObserver: TransactionObserver {
|
|
var id: String { get }
|
|
}
|
|
|
|
// MARK: - PublisherBridge
|
|
|
|
private extension Storage {
|
|
actor PublisherBridge<T> {
|
|
private weak var subject: PassthroughSubject<T, Error>?
|
|
private var isFinished: Bool = false
|
|
|
|
init(subject: PassthroughSubject<T, Error>) {
|
|
self.subject = subject
|
|
}
|
|
|
|
func send(result: Result<T, Error>, info: CallInfo) {
|
|
guard !isFinished, let subject = self.subject else { return }
|
|
|
|
isFinished = true
|
|
|
|
switch result {
|
|
case .success(let value):
|
|
info.complete()
|
|
subject.send(value)
|
|
subject.send(completion: .finished)
|
|
|
|
case .failure(let error):
|
|
info.errored(error)
|
|
subject.send(completion: .failure(error))
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
guard !isFinished else { return }
|
|
|
|
isFinished = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Debug Convenience
|
|
|
|
public extension Storage {
|
|
static let encKeyFilename: String = "key.enc"
|
|
|
|
func testAccess(
|
|
databasePath: String,
|
|
encryptedKeyPath: String,
|
|
encryptedKeyPassword: String
|
|
) throws {
|
|
/// First we need to ensure we can decrypt the encrypted key file
|
|
do {
|
|
var tmpKeySpec: Data = try decryptSecureExportedKey(
|
|
path: encryptedKeyPath,
|
|
password: encryptedKeyPassword
|
|
)
|
|
tmpKeySpec.resetBytes(in: 0..<tmpKeySpec.count)
|
|
}
|
|
catch { return }
|
|
|
|
/// Then configure the database using the key
|
|
var config = Configuration()
|
|
|
|
/// Load in the SQLCipher keys
|
|
config.prepareDatabase { [weak self] db in
|
|
var keySpec: Data = try self?.decryptSecureExportedKey(
|
|
path: encryptedKeyPath,
|
|
password: encryptedKeyPassword
|
|
) ?? { throw KeychainStorageError.keySpecInvalid }()
|
|
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
|
|
|
// Use a raw key spec, where the 96 hexadecimal digits are provided
|
|
// (i.e. 64 hex for the 256 bit key, followed by 32 hex for the 128 bit salt)
|
|
// using explicit BLOB syntax, e.g.:
|
|
//
|
|
// x'98483C6EB40B6C31A448C22A66DED3B5E5E8D5119CAC8327B655C8B5C483648101010101010101010101010101010101'
|
|
keySpec = try (keySpec.toHexString().data(using: .utf8) ?? {
|
|
throw KeychainStorageError.keySpecInvalid
|
|
}())
|
|
keySpec.insert(contentsOf: [120, 39], at: 0) // "x'" prefix
|
|
keySpec.append(39) // "'" suffix
|
|
|
|
try db.usePassphrase(keySpec)
|
|
|
|
// According to the SQLCipher docs iOS needs the 'cipher_plaintext_header_size' value set to at least
|
|
// 32 as iOS extends special privileges to the database and needs this header to be in plaintext
|
|
// to determine the file type
|
|
//
|
|
// For more info see: https://www.zetetic.net/sqlcipher/sqlcipher-api/#cipher_plaintext_header_size
|
|
try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32")
|
|
}
|
|
|
|
// Create the DatabasePool to allow us to connect to the database and mark the storage as valid
|
|
dbWriter = try DatabasePool(path: databasePath, configuration: config)
|
|
isValid = true
|
|
}
|
|
|
|
func secureExportKey(password: String) throws -> String {
|
|
var keySpec: Data = try dependencies[singleton: .keychain].getOrGenerateEncryptionKey(
|
|
forKey: .dbCipherKeySpec,
|
|
length: Storage.SQLCipherKeySpecLength,
|
|
cat: .storage,
|
|
legacyKey: "GRDBDatabaseCipherKeySpec",
|
|
legacyService: "TSKeyChainService"
|
|
)
|
|
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
|
|
|
guard var passwordData: Data = password.data(using: .utf8) else { throw StorageError.generic }
|
|
defer { passwordData.resetBytes(in: 0..<passwordData.count) } // Reset content immediately after use
|
|
|
|
/// Encrypt the `keySpec` value using a SHA256 of the password provided and a nonce then base64-encode the encrypted
|
|
/// data and save it to a temporary file to share alongside the database
|
|
///
|
|
/// Decrypt the key via the termincal on macOS by running the command in the project root directory
|
|
/// `swift ./Scropts/DecryptExportedKey.swift {BASE64_CIPHERTEXT} {PASSWORD}`
|
|
///
|
|
/// Where `BASE64_CIPHERTEXT` is the content of the `key.enc` file and `PASSWORD` is the password provided via the
|
|
/// prompt during export
|
|
let nonce: ChaChaPoly.Nonce = ChaChaPoly.Nonce()
|
|
let hash: SHA256.Digest = SHA256.hash(data: passwordData)
|
|
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
|
|
let sealedBox: ChaChaPoly.SealedBox = try ChaChaPoly.seal(keySpec, using: key, nonce: nonce, authenticating: Data())
|
|
let keyInfoPath: String = "\(dependencies[singleton: .fileManager].temporaryDirectory)/\(Storage.encKeyFilename)"
|
|
let encryptedKeyBase64: String = sealedBox.combined.base64EncodedString()
|
|
try encryptedKeyBase64.write(toFile: keyInfoPath, atomically: true, encoding: .utf8)
|
|
|
|
return keyInfoPath
|
|
}
|
|
|
|
func replaceDatabaseKey(path: String, password: String) throws {
|
|
var keySpec: Data = try decryptSecureExportedKey(path: path, password: password)
|
|
defer { keySpec.resetBytes(in: 0..<keySpec.count) } // Reset content immediately after use
|
|
|
|
try dependencies[singleton: .keychain].set(data: keySpec, forKey: .dbCipherKeySpec)
|
|
}
|
|
|
|
fileprivate func decryptSecureExportedKey(path: String, password: String) throws -> Data {
|
|
let encKeyBase64: String = try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
|
|
|
|
guard
|
|
var passwordData: Data = password.data(using: .utf8),
|
|
var encKeyData: Data = Data(base64Encoded: encKeyBase64)
|
|
else { throw StorageError.generic }
|
|
defer {
|
|
// Reset content immediately after use
|
|
passwordData.resetBytes(in: 0..<passwordData.count)
|
|
encKeyData.resetBytes(in: 0..<encKeyData.count)
|
|
}
|
|
|
|
let hash: SHA256.Digest = SHA256.hash(data: passwordData)
|
|
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
|
|
|
|
let sealedBox: ChaChaPoly.SealedBox = try ChaChaPoly.SealedBox(combined: encKeyData)
|
|
|
|
return try ChaChaPoly.open(sealedBox, using: key, authenticating: Data())
|
|
}
|
|
}
|
|
|
|
/// Function to determine if the debugger is attached
|
|
///
|
|
/// **Note:** Only contains logic when `DEBUG` is defined, otherwise it always returns false
|
|
func isDebuggerAttached() -> Bool {
|
|
#if DEBUG
|
|
var info = kinfo_proc()
|
|
var size = MemoryLayout<kinfo_proc>.stride
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()]
|
|
let sysctlResult = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
|
|
guard sysctlResult == 0 else { return false }
|
|
return (info.kp_proc.p_flag & P_TRACED) != 0
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
private extension String.StringInterpolation {
|
|
mutating func appendInterpolation(_ value: Double, format: String, omitZeroDecimal: Bool = false) {
|
|
guard !omitZeroDecimal || Int(exactly: value) == nil else {
|
|
appendLiteral("\(Int(exactly: value)!)")
|
|
return
|
|
}
|
|
|
|
let result: String = String(format: "%\(format)f", value)
|
|
appendLiteral(result)
|
|
}
|
|
}
|