Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f204e05
fix: serialize backup observation lifecycle
ben-kaufman Oct 25, 2026
538f45c
fix: retain backup changes during observation restart
ben-kaufman Oct 25, 2026
1ad665f
test: wait for unobstructed USDT back navigation
ben-kaufman Oct 25, 2026
494c949
fix: retry backup transport failures and coalesce status writes
ben-kaufman Oct 26, 2026
ff999a5
fix: complete wallet initialization once
ben-kaufman Oct 26, 2026
2416046
merge: adopt current recovery dependency stack
ben-kaufman Oct 27, 2026
14152f4
Merge current identity recovery parent
ben-kaufman Oct 27, 2026
bfc4a5e
fix: retain backup changes while stopped uploads drain
ben-kaufman Oct 27, 2026
e12df99
Merge restore parent and preserve foreground backup lifecycle
ben-kaufman Oct 27, 2026
566b2ff
merge validated backup identity parent
ben-kaufman Oct 28, 2026
d33547c
Merge current identity recovery parent into backup lifecycle
ben-kaufman Oct 29, 2026
8947f49
Merge current identity parent while preserving backup drains
ben-kaufman Oct 29, 2026
1dd6d2f
test: isolate identity publication return deadline
ben-kaufman Oct 29, 2026
262a447
Merge wallet recovery ordering into backup lifecycle guards
ben-kaufman Oct 30, 2026
fb25166
Merge validated restore identity parent into backup lifecycle recovery
ben-kaufman Oct 31, 2026
2c0c100
Merge queued allowance admission fix
ben-kaufman Nov 1, 2026
ff3ba67
Merge current restore identity parent into backup lifecycle
ben-kaufman Nov 1, 2026
c633688
merge: carry cached backup protection into observer recovery
ben-kaufman Nov 3, 2026
4e6c06e
fix: publish backup idle after scheduled admission cleanup
ben-kaufman Nov 3, 2026
11fb65d
fix: retry temporary backup authentication failures
ben-kaufman Nov 3, 2026
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
382 changes: 221 additions & 161 deletions Bitkit/Services/BackupService.swift

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions Bitkit/Views/Onboarding/InitializingWalletView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,22 @@ struct InitializingWalletView: View {
@Binding var nodeLifecycleState: NodeLifecycleState
let onComplete: () -> Void

init(
nodeLifecycleState: Binding<NodeLifecycleState>,
initialPercentage: Double = 0,
onComplete: @escaping () -> Void
) {
_nodeLifecycleState = nodeLifecycleState
_percentage = State(initialValue: initialPercentage)
self.onComplete = onComplete
}

private var shouldFinish: Bool {
nodeLifecycleState == .running
}

private func handleCompletion() {
guard shouldFinish, !shouldStopAnimation else { return }
timer?.invalidate()
shouldStopAnimation = true
Haptics.stopHaptics()
Expand Down
109 changes: 95 additions & 14 deletions BitkitTests/BackupForegroundRecoveryTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,25 @@ final class BackupForegroundRecoveryTests: XCTestCase {
}

func testFailedRecoveryWaitsForOtherRetriesAndPreservesAgeAndCooldown() async throws {
let fixture = try Fixture(categories: [.settings, .widgets])
let fixture = try Fixture(categories: [.usdt, .widgets])
defer { fixture.removeDefaults() }
let uploads = UploadRecorder()
let failed = expectation(description: "Settings retry failed")
let failed = expectation(description: "USDT retry failed")
let started = expectation(description: "Widgets retry started")
let (uploadGate, releaseUpload) = AsyncStream<Void>.makeStream()
defer { releaseUpload.finish() }
let service = fixture.service(uploadBackup: { key, _ in
await uploads.record(key)
if key == BackupCategory.settings.rawValue {
if key == BackupCategory.usdt.rawValue {
throw URLError(.notConnectedToInternet)
}
guard key == BackupCategory.widgets.rawValue else { return }
started.fulfill()
for await _ in uploadGate {}
})
await startAfterPendingJobsSettle(service, categories: fixture.categories)
let statusSubscription = service.backupStatusesPublisher
.map { $0[.settings]?.running == true }
.map { $0[.usdt]?.running == true }
.removeDuplicates()
.drop(while: { !$0 })
.filter { !$0 }
Expand All @@ -73,8 +74,8 @@ final class BackupForegroundRecoveryTests: XCTestCase {
await fulfillment(of: [genuineAlert], timeout: 2)
failureSubscription.cancel()

XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required)
XCTAssertTrue(service.getBackupStatus(category: .settings).isRequired)
XCTAssertEqual(service.getBackupStatus(category: .usdt).required, fixture.required)
XCTAssertTrue(service.getBackupStatus(category: .usdt).isRequired)
XCTAssertFalse(service.getBackupStatus(category: .widgets).isRequired)

let repeatedAlert = expectation(description: "Foreground retry must preserve notification cooldown")
Expand All @@ -85,9 +86,9 @@ final class BackupForegroundRecoveryTests: XCTestCase {
await fulfillment(of: [repeatedAlert], timeout: 0.2)
failureSubscription.cancel()
let recorded = await uploads.counts
XCTAssertEqual(recorded[BackupCategory.settings.rawValue], 2)
XCTAssertEqual(recorded[BackupCategory.usdt.rawValue], 2)
XCTAssertEqual(recorded[BackupCategory.widgets.rawValue], 1)
XCTAssertEqual(service.getBackupStatus(category: .settings).required, fixture.required)
XCTAssertEqual(service.getBackupStatus(category: .usdt).required, fixture.required)
service.setAppActive(false)
await service.stopObservingBackups()
}
Expand Down Expand Up @@ -171,20 +172,31 @@ final class BackupForegroundRecoveryTests: XCTestCase {
let fixture = try Fixture(categories: [.settings])
defer { fixture.removeDefaults() }
let capturing = expectation(description: "Foreground backup capture started")
let (captureGate, releaseCapture) = AsyncStream<Void>.makeStream()
defer { releaseCapture.finish() }
let cancelled = expectation(description: "Stop cancels the captured operation")
let stoppedEarly = expectation(description: "Stop must drain non-cooperative capture")
stoppedEarly.isInverted = true
let gate = CaptureGate()
let service = BackupService(defaults: fixture.defaults, backupData: { _ in
capturing.fulfill()
for await _ in captureGate {}
await withTaskCancellationHandler {
await gate.wait(started: capturing)
} onCancel: { cancelled.fulfill() }
return Data("isolated-snapshot".utf8)
}, uploadBackup: { _, _ in XCTFail("Stopped recovery must not upload") }, waitForBackupDelay: { throw CancellationError() })
await startAfterPendingJobsSettle(service, categories: fixture.categories)

let recovery = service.setAppActive(true)
await fulfillment(of: [capturing], timeout: 2)
service.setWiping(true)
await service.stopObservingBackups()
releaseCapture.finish()
let stop = Task {
await service.stopObservingBackups()
if await gate.isHeld {
stoppedEarly.fulfill()
}
}
await fulfillment(of: [cancelled], timeout: 2)
await fulfillment(of: [stoppedEarly], timeout: 0.1)
await gate.release()
await stop.value
await recovery?.value

let status = service.getBackupStatus(category: .settings)
Expand Down Expand Up @@ -296,6 +308,56 @@ final class BackupForegroundRecoveryTests: XCTestCase {
await service.stopObservingBackups()
}

func testScheduledIdlePublicationWaitsForForegroundAdmissionCleanup() async throws {
let fixture = try Fixture(categories: [.settings])
defer { fixture.removeDefaults() }
let uploads = UploadRecorder()
let delayStarted = expectation(description: "Initial scheduled backup is held")
let queueHeld = expectation(description: "Backup admission queue is held")
let prematureIdle = expectation(description: "Idle must not precede admission cleanup")
prematureIdle.isInverted = true
let settled = expectation(description: "Scheduled backup becomes available for foreground retry")
let (delay, releaseDelay) = AsyncStream<Void>.makeStream()
let releaseQueue = DispatchSemaphore(value: 0)
defer {
releaseDelay.finish()
releaseQueue.signal()
}
let service = fixture.service(uploadBackup: { key, _ in
await uploads.record(key)
}, waitForBackupDelay: {
delayStarted.fulfill()
for await _ in delay {}
throw CancellationError()
})
await service.startObservingBackups()?.value
await fulfillment(of: [delayStarted], timeout: 2)
Bitkit.ServiceQueue.ServiceTypes.backup.queue.async {
queueHeld.fulfill()
XCTAssertEqual(releaseQueue.wait(timeout: .now() + 2), .success)
}
await fulfillment(of: [queueHeld], timeout: 2)
let earlySubscription = service.backupStatusesPublisher
.filter { $0[.settings]?.running == false }
.first().sink { _ in prematureIdle.fulfill() }
releaseDelay.finish()
await fulfillment(of: [prematureIdle], timeout: 0.2)
earlySubscription.cancel()
let settledSubscription = service.backupStatusesPublisher
.filter { $0[.settings]?.running == false }
.first().sink { _ in settled.fulfill() }
defer { settledSubscription.cancel() }
releaseQueue.signal()
await fulfillment(of: [settled], timeout: 2)

await service.setAppActive(true)?.value
let recorded = await uploads.counts
XCTAssertEqual(recorded, [BackupCategory.settings.rawValue: 1])
XCTAssertFalse(service.getBackupStatus(category: .settings).isRequired)
service.setAppActive(false)
await service.stopObservingBackups()
}

private func startAfterPendingJobsSettle(_ service: BackupService, categories: [BackupCategory]) async {
let settled = categories.map { expectation(description: "Initial \($0.rawValue) scheduling settled") }
let subscriptions = zip(categories, settled).map { category, completed in
Expand Down Expand Up @@ -526,3 +588,22 @@ private actor UploadRecorder {
counts[key, default: 0] += 1
}
}

private actor CaptureGate {
private(set) var isHeld = true
private var continuation: CheckedContinuation<Void, Never>?

func wait(started: XCTestExpectation) async {
guard isHeld else { return }
await withCheckedContinuation { continuation in
self.continuation = continuation
started.fulfill()
}
}

func release() {
isHeld = false
continuation?.resume()
continuation = nil
}
}
Loading
Loading