From 58333b558bf8d631025907f52689cf357e01e184 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Fri, 12 Mar 2021 15:25:16 -0800 Subject: [PATCH] 0xDEAD10CC mitigation --- DB/Sources/DB/Content/ContentDatabase.swift | 129 +++++------------- .../Extensions/DatabasePool+Extensions.swift | 1 + .../DatabaseWriter+Extensions.swift | 29 ++++ DB/Sources/DB/Identity/IdentityDatabase.swift | 36 ++--- System/MetatextApp.swift | 28 ++++ 5 files changed, 99 insertions(+), 124 deletions(-) create mode 100644 DB/Sources/DB/Extensions/DatabaseWriter+Extensions.swift diff --git a/DB/Sources/DB/Content/ContentDatabase.swift b/DB/Sources/DB/Content/ContentDatabase.swift index ba9cc3f..383f5c9 100644 --- a/DB/Sources/DB/Content/ContentDatabase.swift +++ b/DB/Sources/DB/Content/ContentDatabase.swift @@ -51,9 +51,7 @@ public extension ContentDatabase { } func insert(status: Status) -> AnyPublisher { - databaseWriter.writePublisher(updates: status.save) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: status.save) } // swiftlint:disable:next function_body_length @@ -61,7 +59,7 @@ public extension ContentDatabase { statuses: [Status], timeline: Timeline, loadMoreAndDirection: (LoadMore, LoadMore.Direction)? = nil) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { let timelineRecord = TimelineRecord(timeline: timeline) try timelineRecord.save($0) @@ -122,12 +120,10 @@ public extension ContentDatabase { } } } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(context: Context, parentId: Status.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for (index, status) in context.ancestors.enumerated() { try status.save($0) try StatusAncestorJoin(parentId: parentId, statusId: status.id, order: index).save($0) @@ -148,12 +144,10 @@ public extension ContentDatabase { && !context.descendants.map(\.id).contains(StatusDescendantJoin.Columns.statusId)) .deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(pinnedStatuses: [Status], accountId: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for (index, status) in pinnedStatuses.enumerated() { try status.save($0) try AccountPinnedStatusJoin(accountId: accountId, statusId: status.id, order: index).save($0) @@ -164,12 +158,10 @@ public extension ContentDatabase { && !pinnedStatuses.map(\.id).contains(AccountPinnedStatusJoin.Columns.statusId)) .deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func toggleShowContent(id: Status.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { if let toggle = try StatusShowContentToggle .filter(StatusShowContentToggle.Columns.statusId == id) .fetchOne($0) { @@ -178,12 +170,10 @@ public extension ContentDatabase { try StatusShowContentToggle(statusId: id).save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func toggleShowAttachments(id: Status.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { if let toggle = try StatusShowAttachmentsToggle .filter(StatusShowAttachmentsToggle.Columns.statusId == id) .fetchOne($0) { @@ -192,23 +182,19 @@ public extension ContentDatabase { try StatusShowAttachmentsToggle(statusId: id).save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func expand(ids: Set) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for id in ids { try StatusShowContentToggle(statusId: id).save($0) try StatusShowAttachmentsToggle(statusId: id).save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func collapse(ids: Set) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { try StatusShowContentToggle .filter(ids.contains(StatusShowContentToggle.Columns.statusId)) .deleteAll($0) @@ -216,29 +202,23 @@ public extension ContentDatabase { .filter(ids.contains(StatusShowContentToggle.Columns.statusId)) .deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func update(id: Status.Id, poll: Poll) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { let data = try StatusRecord.databaseJSONEncoder(for: StatusRecord.Columns.poll.name).encode(poll) try StatusRecord.filter(StatusRecord.Columns.id == id) .updateAll($0, StatusRecord.Columns.poll.set(to: data)) } - .ignoreOutput() - .eraseToAnyPublisher() } func delete(id: Status.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: StatusRecord.filter(StatusRecord.Columns.id == id).deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: StatusRecord.filter(StatusRecord.Columns.id == id).deleteAll) } func unfollow(id: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { let statusIds = try Status.Id.fetchAll( $0, StatusRecord.filter(StatusRecord.Columns.accountId == id).select(StatusRecord.Columns.id)) @@ -248,27 +228,21 @@ public extension ContentDatabase { && statusIds.contains(TimelineStatusJoin.Columns.statusId)) .deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func mute(id: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { try StatusRecord.filter(StatusRecord.Columns.accountId == id).deleteAll($0) try NotificationRecord.filter(NotificationRecord.Columns.accountId == id).deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func block(id: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: AccountRecord.filter(AccountRecord.Columns.id == id).deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: AccountRecord.filter(AccountRecord.Columns.id == id).deleteAll) } func insert(accounts: [Account], listId: AccountList.Id? = nil) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { var order: Int? if let listId = listId { @@ -290,22 +264,18 @@ public extension ContentDatabase { } } } - .ignoreOutput() - .eraseToAnyPublisher() } func remove(id: Account.Id, from listId: AccountList.Id) -> AnyPublisher { - databaseWriter.writePublisher( + databaseWriter.mutatingPublisher( updates: AccountListJoin.filter( AccountListJoin.Columns.accountId == id && AccountListJoin.Columns.accountListId == listId) .deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() } func insert(identityProofs: [IdentityProof], id: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for identityProof in identityProofs { try IdentityProofRecord( accountId: id, @@ -317,12 +287,10 @@ public extension ContentDatabase { .save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(featuredTags: [FeaturedTag], id: Account.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for featuredTag in featuredTags { try FeaturedTagRecord( id: featuredTag.id, @@ -334,22 +302,18 @@ public extension ContentDatabase { .save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(relationships: [Relationship]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for relationship in relationships { try relationship.save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func setLists(_ lists: [List]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for list in lists { try TimelineRecord(timeline: Timeline.list(list)).save($0) } @@ -359,86 +323,66 @@ public extension ContentDatabase { && TimelineRecord.Columns.listTitle != nil) .deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func createList(_ list: List) -> AnyPublisher { - databaseWriter.writePublisher(updates: TimelineRecord(timeline: Timeline.list(list)).save) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: TimelineRecord(timeline: Timeline.list(list)).save) } func deleteList(id: List.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: TimelineRecord.filter(TimelineRecord.Columns.listId == id).deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: TimelineRecord.filter(TimelineRecord.Columns.listId == id).deleteAll) } func setFilters(_ filters: [Filter]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for filter in filters { try filter.save($0) } try Filter.filter(!filters.map(\.id).contains(Filter.Columns.id)).deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func createFilter(_ filter: Filter) -> AnyPublisher { - databaseWriter.writePublisher(updates: filter.save) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: filter.save) } func deleteFilter(id: Filter.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: Filter.filter(Filter.Columns.id == id).deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: Filter.filter(Filter.Columns.id == id).deleteAll) } func setLastReadId(_ id: String, timelineId: Timeline.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: LastReadIdRecord(timelineId: timelineId, id: id).save) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: LastReadIdRecord(timelineId: timelineId, id: id).save) } func insert(notifications: [MastodonNotification]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for notification in notifications { try notification.save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(conversations: [Conversation]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for conversation in conversations { try conversation.save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func update(emojis: [Emoji]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for emoji in emojis { try emoji.save($0) } try Emoji.filter(!emojis.map(\.shortcode).contains(Emoji.Columns.shortcode)).deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func updateUse(emoji: String, system: Bool) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { let count = try Int.fetchOne( $0, EmojiUse.filter(EmojiUse.Columns.system == system && EmojiUse.Columns.emoji == emoji) @@ -446,24 +390,20 @@ public extension ContentDatabase { try EmojiUse(emoji: emoji, system: system, lastUse: Date(), count: (count ?? 0) + 1).save($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func update(announcements: [Announcement]) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for announcement in announcements { try announcement.save($0) } try Announcement.filter(!announcements.map(\.id).contains(Announcement.Columns.id)).deleteAll($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(results: Results) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { for account in results.accounts { try account.save($0) } @@ -472,14 +412,10 @@ public extension ContentDatabase { try status.save($0) } } - .ignoreOutput() - .eraseToAnyPublisher() } func insert(instance: Instance) -> AnyPublisher { - databaseWriter.writePublisher(updates: instance.save) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: instance.save) } func timelinePublisher(_ timeline: Timeline) -> AnyPublisher<[CollectionSection], Error> { @@ -697,7 +633,6 @@ public extension ContentDatabase { private extension ContentDatabase { static let cleanAfterLastReadIdCount = 40 - static let ephemeralTimelines = NSCountedSet() static func fileURL(id: Identity.Id, appGroup: String) throws -> URL { diff --git a/DB/Sources/DB/Extensions/DatabasePool+Extensions.swift b/DB/Sources/DB/Extensions/DatabasePool+Extensions.swift index 6ac644f..80d9845 100644 --- a/DB/Sources/DB/Extensions/DatabasePool+Extensions.swift +++ b/DB/Sources/DB/Extensions/DatabasePool+Extensions.swift @@ -20,6 +20,7 @@ extension DatabasePool { configuration.busyMode = .timeout(5) configuration.defaultTransactionKind = .immediate + configuration.observesSuspensionNotifications = true configuration.prepareDatabase { db in try db.usePassphrase(passphrase()) try db.execute(sql: "PRAGMA cipher_plaintext_header_size = 32") diff --git a/DB/Sources/DB/Extensions/DatabaseWriter+Extensions.swift b/DB/Sources/DB/Extensions/DatabaseWriter+Extensions.swift new file mode 100644 index 0000000..e48d873 --- /dev/null +++ b/DB/Sources/DB/Extensions/DatabaseWriter+Extensions.swift @@ -0,0 +1,29 @@ +// Copyright © 2021 Metabolist. All rights reserved. + +import Combine +import Foundation +import GRDB + +// swiftlint:disable:next line_length +// https://github.com/groue/GRDB.swift/blob/master/Documentation/SharingADatabase.md#how-to-limit-the-0xdead10cc-exception + +extension DatabaseWriter { + func mutatingPublisher(updates: @escaping (Database) throws -> Output) -> AnyPublisher { + let publisher = writePublisher(updates: updates) + + return publisher + .tryCatch { error -> AnyPublisher in + if let databaseError = error as? DatabaseError, databaseError.isInterruptionError { + return NotificationCenter.default.publisher(for: Database.resumeNotification) + .timeout(.seconds(1), scheduler: DispatchQueue.global()) + .flatMap { _ in publisher } + .eraseToAnyPublisher() + } else { + throw error + } + } + .retry(1) + .ignoreOutput() + .eraseToAnyPublisher() + } +} diff --git a/DB/Sources/DB/Identity/IdentityDatabase.swift b/DB/Sources/DB/Identity/IdentityDatabase.swift index 77af7bc..0016073 100644 --- a/DB/Sources/DB/Identity/IdentityDatabase.swift +++ b/DB/Sources/DB/Identity/IdentityDatabase.swift @@ -32,7 +32,7 @@ public struct IdentityDatabase { public extension IdentityDatabase { func createIdentity(id: Identity.Id, url: URL, authenticated: Bool, pending: Bool) -> AnyPublisher { - databaseWriter.writePublisher( + databaseWriter.mutatingPublisher( updates: IdentityRecord( id: id, url: url, @@ -44,28 +44,22 @@ public extension IdentityDatabase { lastRegisteredDeviceToken: nil, pushSubscriptionAlerts: .initial) .save) - .ignoreOutput() - .eraseToAnyPublisher() } func deleteIdentity(id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: IdentityRecord.filter(IdentityRecord.Columns.id == id).deleteAll) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: IdentityRecord.filter(IdentityRecord.Columns.id == id).deleteAll) } func updateLastUsedAt(id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { try IdentityRecord .filter(IdentityRecord.Columns.id == id) .updateAll($0, IdentityRecord.Columns.lastUsedAt.set(to: Date())) } - .ignoreOutput() - .eraseToAnyPublisher() } func updateInstance(_ instance: Instance, id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { try Identity.Instance( uri: instance.uri, streamingAPI: instance.urls.streamingApi, @@ -78,12 +72,10 @@ public extension IdentityDatabase { .filter(IdentityRecord.Columns.id == id) .updateAll($0, IdentityRecord.Columns.instanceURI.set(to: instance.uri)) } - .ignoreOutput() - .eraseToAnyPublisher() } func updateAccount(_ account: Account, id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher( + databaseWriter.mutatingPublisher( updates: Identity.Account( id: account.id, identityId: id, @@ -97,22 +89,18 @@ public extension IdentityDatabase { emojis: account.emojis, followRequestCount: account.source?.followRequestsCount ?? 0) .save) - .ignoreOutput() - .eraseToAnyPublisher() } func confirmIdentity(id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { try IdentityRecord .filter(IdentityRecord.Columns.id == id) .updateAll($0, IdentityRecord.Columns.pending.set(to: false)) } - .ignoreOutput() - .eraseToAnyPublisher() } func updatePreferences(_ preferences: Mastodon.Preferences, id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { guard let storedPreferences = try IdentityRecord.filter(IdentityRecord.Columns.id == id) .fetchOne($0)? .preferences else { @@ -121,20 +109,16 @@ public extension IdentityDatabase { try Self.writePreferences(storedPreferences.updated(from: preferences), id: id)($0) } - .ignoreOutput() - .eraseToAnyPublisher() } func updatePreferences(_ preferences: Identity.Preferences, id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher(updates: Self.writePreferences(preferences, id: id)) - .ignoreOutput() - .eraseToAnyPublisher() + databaseWriter.mutatingPublisher(updates: Self.writePreferences(preferences, id: id)) } func updatePushSubscription(alerts: PushSubscription.Alerts, deviceToken: Data? = nil, id: Identity.Id) -> AnyPublisher { - databaseWriter.writePublisher { + databaseWriter.mutatingPublisher { let data = try IdentityRecord.databaseJSONEncoder( for: IdentityRecord.Columns.pushSubscriptionAlerts.name) .encode(alerts) @@ -149,8 +133,6 @@ public extension IdentityDatabase { .updateAll($0, IdentityRecord.Columns.lastRegisteredDeviceToken.set(to: deviceToken)) } } - .ignoreOutput() - .eraseToAnyPublisher() } func identityPublisher(id: Identity.Id, immediate: Bool) -> AnyPublisher { diff --git a/System/MetatextApp.swift b/System/MetatextApp.swift index 8383a04..73803f5 100644 --- a/System/MetatextApp.swift +++ b/System/MetatextApp.swift @@ -1,6 +1,8 @@ // Copyright © 2020 Metabolist. All rights reserved. import AVKit +import Combine +import GRDB import ServiceLayer import SwiftUI import ViewModels @@ -8,10 +10,36 @@ import ViewModels @main struct MetatextApp: App { @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + private var cancellables = Set() init() { try? AVAudioSession.sharedInstance().setCategory(.ambient, mode: .default) try? ImageCacheConfiguration(environment: Self.environment).configure() + + // swiftlint:disable:next line_length + // https://github.com/groue/GRDB.swift/blob/master/Documentation/SharingADatabase.md#how-to-limit-the-0xdead10cc-exception + // This would ideally be accomplished with `@Environment(\.scenePhase) private var scenePhase` + // and `.onChange(of: scenePhase)` on the `WindowGroup`, but that does not give an accurate + // aggregate scene activation state for iPad multitasking as of iOS 14.4.1 + Publishers.MergeMany([UIScene.willConnectNotification, + UIScene.didDisconnectNotification, + UIScene.didActivateNotification, + UIScene.willDeactivateNotification, + UIScene.willEnterForegroundNotification, + UIScene.didEnterBackgroundNotification] + .map { NotificationCenter.default.publisher(for: $0) }) + .map { _ in + UIApplication.shared.openSessions + .compactMap(\.scene) + .allSatisfy { $0.activationState == .background } + } + .removeDuplicates() + .sink { + NotificationCenter.default.post( + name: $0 ? Database.suspendNotification : Database.resumeNotification, + object: nil) + } + .store(in: &cancellables) } var body: some Scene {