From 0e95422c1b2869b9fe4a6db0d80e122335bf425a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 15:35:16 -0400 Subject: [PATCH 1/3] feat(nse): report crashes from the notification service extension The extension runs in its own process and `ErrorReporting` lives in the app target, so an NSE crash currently produces no report at all. `ExtensionReporting` starts Bugsnag once per extension process and tags events with `process = notification-service`. Without that tag both processes report under the same app id and the extension's crashes are indistinguishable from the app's. It stays in the extension target rather than moving into FlipcashCore, which would put Bugsnag into the core package's dependency graph and its macOS vector test plans. A jetsam kill for exceeding the extension's memory limit terminates the process without an exception, so it still produces nothing here. Memory headroom has to be measured, not inferred from silence. --- Code.xcodeproj/project.pbxproj | 3 + NotificationService/ExtensionReporting.swift | 74 +++++++++++++++++++ NotificationService/Info.plist | 5 ++ NotificationService/NotificationService.swift | 4 + 4 files changed, 86 insertions(+) create mode 100644 NotificationService/ExtensionReporting.swift 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/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() } } From dd66301bcbfc67e9efdb1eaeaabd975bec6541d5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 15:35:26 -0400 Subject: [PATCH 2/3] feat(database): add a write-ahead log checkpoint `PRAGMA wal_checkpoint(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. TRUNCATE blocks up to `busyTimeout` and throws when it cannot finish. No production caller yet. It gains one in the follow-up that wires `close()` to app lifecycle; landing the pragma and its tests separately keeps that change to connection lifecycle alone, which is the part with real blast radius. --- .../Core/Controllers/Database/Database.swift | 11 ++++ FlipcashTests/DatabaseLifecycleTests.swift | 62 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 FlipcashTests/DatabaseLifecycleTests.swift 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/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) + } +} From 69214574e9d4230db6daeca46be032ffa3479110 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 10 Sep 2026 15:35:27 -0400 Subject: [PATCH 3/3] chore(dev): add launch-argument hooks for on-device push testing `0xdead10cc`, the watchdog kill for holding a lock on shared storage while suspended, is the one risk in the App Group migration a simulator cannot reproduce, so the store move has to be re-checked on hardware. On a physical device there is otherwise no way to set a beta flag (Maestro does not support physical iOS devices, and devicectl cannot inject touches), no way to reach `.authorized` (the notification prompt is reachable only from onboarding or a money flow, and without it there is no APNs token at all), and no way to read the FCM token (the log redactor strips it). All three are gated on `CommandLine.arguments`, so none can run for a user. `--beta-flags=` sits outside the `--ui-testing` branch because that flag also suppresses keychain auto-login, which would leave the launch with no session and no open database. --- Flipcash/Core/AppDelegate.swift | 27 ++++++++++++++++ .../Core/Controllers/PushController.swift | 31 +++++++++++++++++++ 2 files changed, 58 insertions(+) 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/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 -