Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Code.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -156,6 +157,7 @@
buildActionMask = 2147483647;
files = (
4C7D80012FC8892D0091C7A4 /* FlipcashCore in Frameworks */,
08B8484332ED78035E9D399B /* Bugsnag in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -320,6 +322,7 @@
name = NotificationService;
packageProductDependencies = (
9ABDD1942D9D7B61006B6CDA /* FlipcashCore */,
9ADEF1D62DD627C0001B260A /* Bugsnag */,
);
productName = NotificationService;
productReference = 4C7D6FFA2FC8892D0091C7A4 /* NotificationService.appex */;
Expand Down
27 changes: 27 additions & 0 deletions Flipcash/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions Flipcash/Core/Controllers/Database/Database.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions Flipcash/Core/Controllers/PushController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import UIKit
import UniformTypeIdentifiers
import FlipcashCore

import Firebase
Expand Down Expand Up @@ -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 -
Expand Down
62 changes: 62 additions & 0 deletions FlipcashTests/DatabaseLifecycleTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
74 changes: 74 additions & 0 deletions NotificationService/ExtensionReporting.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions NotificationService/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<dict>
<key>NSContactsUsageDescription</key>
<string>Personalizes notifications with your contact names.</string>
<key>bugsnag</key>
<dict>
<key>apiKey</key>
<string>$(FLIPCASH_BUGSNAG)</string>
</dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
Expand Down
4 changes: 4 additions & 0 deletions NotificationService/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
Expand Down
Loading