diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index 59195821b..3ea3ef5e7 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 47DDE1F353D72DB6C899D6E0 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EC0A077F6E7EE1EB0853134B /* Foundation.framework */; }; 4C7D70012FC8892D0091C7A4 /* NotificationService.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; + 08B8484332ED78035E9D399B /* Bugsnag in Frameworks */ = {isa = PBXBuildFile; productRef = 9ADEF1D62DD627C0001B260A /* Bugsnag */; }; 4CB225762F77260D0075874E /* FirebaseInstallations in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225752F77260D0075874E /* FirebaseInstallations */; }; 4CB225782F7726500075874E /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 4CB225772F7726500075874E /* FirebaseMessaging */; }; 508AF9C6D67C4CD29DE10A16 /* TweetNacl in Frameworks */ = {isa = PBXBuildFile; productRef = 2B166ABF94584FEF9C368C81 /* TweetNacl */; }; @@ -156,6 +157,7 @@ buildActionMask = 2147483647; files = ( 4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */, + 08B8484332ED78035E9D399B /* Bugsnag in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -320,6 +322,7 @@ name = NotificationService; packageProductDependencies = ( 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */, + 9ADEF1D62DD627C0001B260A /* Bugsnag */, ); productName = NotificationService; productReference = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */; diff --git a/Flipcash/Core/AppDelegate.swift b/Flipcash/Core/AppDelegate.swift index a307a44e3..27e277060 100644 --- a/Flipcash/Core/AppDelegate.swift +++ b/Flipcash/Core/AppDelegate.swift @@ -56,6 +56,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if Container.isRunningUITests { UIView.setAnimationsEnabled(false) BetaFlags.shared.applyLaunchArgumentOverrides() + } else if CommandLine.arguments.contains(where: { $0.hasPrefix("--beta-flags=") }) { + // Developer-only, and deliberately not behind `--ui-testing`: that + // flag also suppresses auto-login from the keychain, so a launch + // that sets flags this way still has a session and an open + // database. Setting the flags on a physical device otherwise means + // tapping the toggles by hand — Maestro does not support physical + // iOS devices, and devicectl cannot inject touches. + BetaFlags.shared.applyLaunchArgumentOverrides() + } + + if CommandLine.arguments.contains("--request-push") { + // Developer-only. The notification prompt is otherwise reachable + // only from onboarding or a money flow (swap, currency launch, add + // money), so a device that skipped onboarding has no way to reach + // `.authorized` — and without it there is no APNs token, no FCM + // token, and no way to send the extension a push at all. The user + // still has to tap Allow; this only puts the prompt on screen. + Task { @MainActor in + _ = try? await PushController.authorizeAndRegister() + } + } + + if CommandLine.arguments.contains("--copy-push-token") { + // Developer-only. See PushController.copyTokenToPasteboard. + Task { @MainActor in + await PushController.copyTokenToPasteboard() + } } NotificationCenter.default.addObserver( diff --git a/Flipcash/Core/Controllers/Database/Database.swift b/Flipcash/Core/Controllers/Database/Database.swift index 5b3419ee8..2fcce265c 100644 --- a/Flipcash/Core/Controllers/Database/Database.swift +++ b/Flipcash/Core/Controllers/Database/Database.swift @@ -85,6 +85,17 @@ nonisolated class Database: @unchecked Sendable { } } + // MARK: - Lifecycle - + + /// Flushes the write-ahead log back into the main database file and truncates it. + /// + /// TRUNCATE rather than PASSIVE: a passive checkpoint gives up silently when any + /// reader is mid-transaction, which is the case that leaves the WAL growing without + /// bound. This blocks up to `busyTimeout` instead, and throws when it cannot finish. + func checkpoint() throws { + try writer.run("PRAGMA wal_checkpoint(TRUNCATE);") + } + // MARK: - Versioning - static func deleteStore(owner: PublicKey) throws { diff --git a/Flipcash/Core/Controllers/PushController.swift b/Flipcash/Core/Controllers/PushController.swift index 589a4756f..6f1523f10 100644 --- a/Flipcash/Core/Controllers/PushController.swift +++ b/Flipcash/Core/Controllers/PushController.swift @@ -6,6 +6,7 @@ // import UIKit +import UniformTypeIdentifiers import FlipcashCore import Firebase @@ -222,6 +223,36 @@ extension PushController { static func installationID() async throws -> String { try await Installations.installations().installationID() } + + /// Developer-only: puts the current FCM token on the device pasteboard. + /// + /// Sending a test push to a physical device needs the token, and there is + /// no way to read it off the device otherwise — the log redactor strips it + /// (correctly), Firebase keeps it in the keychain, and the extension is + /// only reachable through a real push. The pasteboard is the one channel + /// `devicectl` can read back. Triggered by `--copy-push-token`, so it never + /// runs for a user and never persists the token to disk. + static func copyTokenToPasteboard() async { + // Firebase refuses to mint an FCM token before APNs has handed one over, + // and that round trip is not finished at launch. Retry rather than fail: + // the alternative is racing the network on every attempt. + for attempt in 1...20 { + do { + let token = try await Messaging.messaging().token() + await MainActor.run { + UIPasteboard.general.setItems( + [[UTType.utf8PlainText.identifier: token]], + options: [.localOnly: true] + ) + } + logger.info("Copied FCM token to pasteboard", metadata: ["attempt": "\(attempt)"]) + return + } catch { + try? await Task.sleep(for: .seconds(2)) + } + } + logger.error("Failed to copy FCM token: no APNs token after 40s") + } } // MARK: - UNUserNotificationCenterDelegate - diff --git a/FlipcashTests/DatabaseLifecycleTests.swift b/FlipcashTests/DatabaseLifecycleTests.swift new file mode 100644 index 000000000..38aa73201 --- /dev/null +++ b/FlipcashTests/DatabaseLifecycleTests.swift @@ -0,0 +1,62 @@ +// +// DatabaseLifecycleTests.swift +// FlipcashTests +// + +import Foundation +import Testing +@testable import Flipcash + +@Suite("Database lifecycle") +struct DatabaseLifecycleTests { + + /// A store in a fresh temporary directory, plus the URL of its write-ahead log. + private func makeDatabase() throws -> (database: Database, walURL: URL) { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("db-\(UUID().uuidString).sqlite") + return (try Database(url: url), URL(fileURLWithPath: url.path + "-wal")) + } + + private func size(of url: URL) -> Int { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attributes?[.size] as? Int) ?? 0 + } + + @Test("a checkpoint empties the write-ahead log") + func checkpointEmptiesWAL() throws { + let (database, walURL) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + for index in 0..<200 { + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "row-\(index)") + } + + // A non-empty WAL is also the check that `journal_mode = WAL` took effect at init; + // in any other journal mode there would be no `-wal` file to measure. + #expect(size(of: walURL) > 0) + + try database.checkpoint() + + #expect(size(of: walURL) == 0) + } + + @Test("data written before a checkpoint survives it") + func checkpointPreservesData() throws { + let (database, _) = try makeDatabase() + try database.writer.run("CREATE TABLE probe (id INTEGER PRIMARY KEY, value TEXT);") + try database.writer.run("INSERT INTO probe (value) VALUES (?);", "kept") + + try database.checkpoint() + + let value = try database.reader.scalar("SELECT value FROM probe LIMIT 1;") as? String + #expect(value == "kept") + } + + @Test("checkpointing an untouched store is not an error") + func checkpointWithNothingToFlush() throws { + let (database, walURL) = try makeDatabase() + + try database.checkpoint() + + #expect(size(of: walURL) == 0) + } +} diff --git a/NotificationService/ExtensionReporting.swift b/NotificationService/ExtensionReporting.swift new file mode 100644 index 000000000..5f0147267 --- /dev/null +++ b/NotificationService/ExtensionReporting.swift @@ -0,0 +1,74 @@ +// +// ExtensionReporting.swift +// NotificationService +// + +import Foundation +import Bugsnag + +/// Crash and error reporting for the notification service extension process. +/// +/// Deliberately separate from the app's `ErrorReporting`: that type lives in the +/// app target, and hoisting it into `FlipcashCore` would make the whole core +/// package — including the macOS vector test plans — depend on Bugsnag. +/// +/// Note what this cannot see. A jetsam kill for exceeding the extension's memory +/// limit terminates the process without an exception, so it produces no report. +/// Memory headroom has to be measured, not inferred from silence here. +enum ExtensionReporting { + + private static let lock = NSLock() + // Guarded by `lock`, not by isolation — the extension calls this from ordinary + // dispatch queues, not actors, so a lock is the synchronization mechanism here. + private static nonisolated(unsafe) var _isStarted = false + + private static var isStarted: Bool { + lock.lock() + defer { lock.unlock() } + return _isStarted + } + + /// Starts Bugsnag once per extension process. Safe to call on every push: + /// the extension is torn down and relaunched often, and each new process + /// needs its own start. + static func startIfNeeded() { + lock.lock() + defer { lock.unlock() } + guard !_isStarted else { return } + + let config = BugsnagConfiguration.loadConfig() + config.maxStringValueLength = 50_000 + // Distinguishes extension events from the app's in the Bugsnag dashboard; + // without it both processes report under the same app id and the NSE's + // crashes are indistinguishable from the app's. + config.addMetadata("notification-service", key: "process", section: "app") + Bugsnag.start(with: config) + _isStarted = true + } + + /// Reports a non-fatal from the extension. No-op before `startIfNeeded`. + static func capture(_ error: Swift.Error, reason: String, metadata: [String: String] = [:]) { + guard isStarted else { return } + Bugsnag.notifyError(error as NSError) { event in + event.severity = .error + if !event.errors.isEmpty { + event.errors[0].errorClass = reason + event.errors[0].errorMessage = "\(error)" + } + metadata.forEach { key, value in + event.addMetadata(value, key: key, section: "extension") + } + event.groupingHash = reason + return true + } + } + + /// Records a named checkpoint so a later crash report shows how far the + /// extension got. A `0xdead10cc` termination arrives with no Swift error + /// attached, so the breadcrumb trail is the only evidence of where the + /// process was when it died. + static func breadcrumb(_ message: String, metadata: [String: String] = [:]) { + guard isStarted else { return } + Bugsnag.leaveBreadcrumb(message, metadata: metadata, type: .process) + } +} diff --git a/NotificationService/Info.plist b/NotificationService/Info.plist index 6e1aeaacb..c66fecb7e 100644 --- a/NotificationService/Info.plist +++ b/NotificationService/Info.plist @@ -4,6 +4,11 @@ NSContactsUsageDescription Personalizes notifications with your contact names. + bugsnag + + apiKey + $(FLIPCASH_BUGSNAG) + NSExtension NSExtensionAttributes diff --git a/NotificationService/NotificationService.swift b/NotificationService/NotificationService.swift index bd8f9a1e7..c503598d6 100644 --- a/NotificationService/NotificationService.swift +++ b/NotificationService/NotificationService.swift @@ -93,6 +93,9 @@ final class NotificationService: UNNotificationServiceExtension { _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { + ExtensionReporting.startIfNeeded() + ExtensionReporting.breadcrumb("didReceive") + guard let bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent else { contentHandler(request.content) return @@ -317,6 +320,7 @@ final class NotificationService: UNNotificationServiceExtension { } catch { // Best-effort prefetch — a transport failure: the content extension falls back to a live // fetch on open. + ExtensionReporting.capture(error, reason: "Notification preview prefetch failed") deliver() } }