diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 300cf1ac0..f9a36c1e4 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1,6 +1,7 @@ import Combine import LDKNode import SwiftUI +import UserNotifications struct AppScene: View { @Environment(\.scenePhase) var scenePhase @@ -32,7 +33,7 @@ struct AppScene: View { @State private var isPinVerified: Bool = false @State private var showRecoveryScreen = false - // Check if there's a critical update available + /// Check if there's a critical update available private var hasCriticalUpdate: Bool { AppUpdateService.shared.availableUpdate?.critical == true } @@ -73,7 +74,6 @@ struct AppScene: View { config in ForgotPinSheet(config: config) } .task(priority: .userInitiated, setupTask) - .handleLightningStateOnScenePhaseChange() .onChange(of: currency.hasStaleData, perform: handleCurrencyStaleData) .onChange(of: wallet.walletExists, perform: handleWalletExistsChange) .onChange(of: wallet.nodeLifecycleState, perform: handleNodeLifecycleChange) @@ -139,7 +139,6 @@ struct AppScene: View { } } - @ViewBuilder private var mainContent: some View { ZStack { if migrations.isShowingMigrationLoading { @@ -160,7 +159,6 @@ struct AppScene: View { } } - @ViewBuilder private var migrationLoadingContent: some View { VStack(spacing: 0) { NavigationBar(title: t("migration__title"), showBackButton: false, showMenuButton: false) @@ -250,7 +248,6 @@ struct AppScene: View { } } - @ViewBuilder private var onboardingContent: some View { NavigationStack { TermsView() @@ -496,13 +493,37 @@ struct AppScene: View { } } - private func handleScenePhaseChange(_: ScenePhase) { - // If PIN is enabled, lock the app when the app goes to the background - if scenePhase == .background && settings.pinEnabled { - isPinVerified = false + private func handleScenePhaseChange(_ newPhase: ScenePhase) { + Logger.debug("Scene phase changed: \(newPhase)") + + if newPhase == .background { + if settings.pinEnabled { + // If PIN is enabled, lock the app when the app goes to the background + isPinVerified = false + } + if wallet.walletExists == true { + app.resetAppStatusInit() + } + } + + if newPhase == .active { + if wallet.walletExists == true { + Task { + await clearDeliveredNotifications() + await LightningService.shared.reconnectPeers() + } + } } } + /// Removes all delivered notifications from Notification Center so the app can handle them when opened. + private func clearDeliveredNotifications() async { + let center = UNUserNotificationCenter.current() + let deliveredNotifications = await center.deliveredNotifications() + guard !deliveredNotifications.isEmpty else { return } + center.removeDeliveredNotifications(withIdentifiers: deliveredNotifications.map(\.request.identifier)) + } + private func handleNetworkRestored() { // Refresh currency rates when network is restored - critical for UI // to display balances (MoneyText returns "0" if rates are nil) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 2bfa4a278..4a625f461 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -383,6 +383,28 @@ class LightningService { } } + func reconnectPeers() async { + guard let node else { + Logger.debug("Node not setup, skipping peer reconnection", context: "LightningService") + return + } + + let peers = node.listPeers() + Logger.debug("Reconnecting to \(peers.count) peers", context: "LightningService") + for peer in peers { + do { + try await ServiceQueue.background(.ldk) { + try node.connect(nodeId: peer.nodeId, address: peer.address, persist: true) + } + Logger.info("Reconnected to peer: \(peer.nodeId)@\(peer.address)", context: "LightningService") + } catch { + Logger.error(error, context: "Failed to reconnect to peer: \(peer.nodeId)@\(peer.address)") + } + } + + Logger.debug("Finished peer reconnection attempt", context: "LightningService") + } + /// Temp fix for regtest where nodes might not agree on current fee rates private func setMaxDustHtlcExposureForCurrentChannels() throws { guard Env.network == .regtest else { @@ -859,14 +881,30 @@ class LightningService { // MARK: UI Helpers (Published via WalletViewModel) extension LightningService { - var nodeId: String? { node?.nodeId() } + var nodeId: String? { + node?.nodeId() + } + + /// Use cached values to avoid blocking LDK calls on main thread + @MainActor var balances: BalanceDetails? { + cachedBalances + } - // Use cached values to avoid blocking LDK calls on main thread - @MainActor var balances: BalanceDetails? { cachedBalances } - @MainActor var status: NodeStatus? { cachedStatus } - @MainActor var peers: [PeerDetails]? { cachedPeers } - @MainActor var channels: [ChannelDetails]? { cachedChannels } - var payments: [PaymentDetails]? { node?.listPayments() } + @MainActor var status: NodeStatus? { + cachedStatus + } + + @MainActor var peers: [PeerDetails]? { + cachedPeers + } + + @MainActor var channels: [ChannelDetails]? { + cachedChannels + } + + var payments: [PaymentDetails]? { + node?.listPayments() + } /// Refresh all cached values asynchronously /// Fetches from LDK on background queue, updates cache on main actor @@ -1376,14 +1414,12 @@ extension LightningService { } return try await ServiceQueue.background(.ldk) { - let fee = try node.onchainPayment().calculateTotalFee( + return try node.onchainPayment().calculateTotalFee( address: address, amountSats: amountSats, feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVByte), utxosToSpend: utxosToSpend ) - - return fee } } @@ -1421,9 +1457,7 @@ extension LightningService { feesMsat = try node.bolt11Payment().estimateRoutingFees(invoice: invoice) } - let feeSat = feesMsat / 1000 - - return feeSat + return feesMsat / 1000 } } diff --git a/Bitkit/Utilities/ScenePhase.swift b/Bitkit/Utilities/ScenePhase.swift deleted file mode 100644 index 9f2628170..000000000 --- a/Bitkit/Utilities/ScenePhase.swift +++ /dev/null @@ -1,213 +0,0 @@ -import SwiftUI -import UIKit -import UserNotifications - -private struct HandleLightningStateOnScenePhaseChange: ViewModifier { - @Environment(\.scenePhase) var scenePhase - @EnvironmentObject var wallet: WalletViewModel - @EnvironmentObject var app: AppViewModel - @EnvironmentObject var sheets: SheetViewModel - @EnvironmentObject var currency: CurrencyViewModel - @EnvironmentObject var blocktank: BlocktankViewModel - - // Store the background task identifier - @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid - // Track if we need to start node after it finishes stopping - @State private var pendingStartAfterStop = false - // Delay before stopping node (don't stop for quick background trips) - @State private var stopNodeWorkItem: DispatchWorkItem? - - // Only stop node if app has been in background for this long - private let backgroundStopDelay: TimeInterval = 90.0 - - func body(content: Content) -> some View { - content - .onChange(of: scenePhase, perform: { newPhase in - guard wallet.walletExists == true else { - return - } - - Logger.debug("Scene phase changed: \(newPhase)") - - if newPhase == .background { - app.resetAppStatusInit() - pendingStartAfterStop = false - scheduleNodeStop() - return - } - - if newPhase == .active { - // Cancel any pending node stop - cancelScheduledNodeStop() - - // End background task if it's still active - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - backgroundTaskID = .invalid - Logger.debug("Ended background task on app becoming active") - } - - // Remove delivered notifications - Task { - await clearDeliveredNotifications() - } - - startNodeIfNeeded() - - Task { - await currency.refresh() - } - - Task { - try? await blocktank.refreshOrders() - } - } - }) - .onChange(of: wallet.nodeLifecycleState, perform: { newState in - // Handle pending start after node finishes stopping - if newState == .stopped && pendingStartAfterStop && scenePhase == .active { - pendingStartAfterStop = false - startNodeIfNeeded() - } - }) - } - - /// Schedule node stop after a delay - allows quick background trips without restart - func scheduleNodeStop() { - // Cancel any existing scheduled stop - stopNodeWorkItem?.cancel() - - let workItem = DispatchWorkItem { [self] in - stopNodeIfNeeded() - } - stopNodeWorkItem = workItem - - Logger.debug("Scheduling node stop in \(backgroundStopDelay)s...") - DispatchQueue.main.asyncAfter(deadline: .now() + backgroundStopDelay, execute: workItem) - } - - /// Cancel scheduled node stop (called when returning to foreground quickly) - func cancelScheduledNodeStop() { - if let workItem = stopNodeWorkItem, !workItem.isCancelled { - workItem.cancel() - Logger.debug("Cancelled scheduled node stop - quick return to foreground") - } - stopNodeWorkItem = nil - } - - func stopNodeIfNeeded() { - // Already stopped or stopping - if wallet.nodeLifecycleState == .stopped || wallet.nodeLifecycleState == .stopping { - return - } - - if wallet.nodeLifecycleState == .starting { - Logger.debug("Node is starting, can't stop yet") - return - } - - guard scenePhase != .active else { - Logger.debug("Scene phase is active, abandoning node stop...") - return - } - - guard wallet.nodeLifecycleState == .running else { - Logger.debug("LN is not in a stoppable state: \(wallet.nodeLifecycleState)") - return - } - - // Begin a background task to request more execution time - backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StopLightningNode") { - // This closure is called if the task expires - Logger.debug("Background task for stopping Lightning node expired before completion") - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - backgroundTaskID = .invalid - } - } - - Logger.debug("Background task started with ID: \(backgroundTaskID.rawValue)") - Logger.debug("App backgrounded, stopping node...") - - Task { - do { - try await wallet.stopLightningNode() - - await MainActor.run { - // End the background task if completed successfully - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - backgroundTaskID = .invalid - Logger.debug("Background task ended after successful node stop") - } - - // If we're stopped and we're not in the background, we need to start again - if scenePhase == .active { - startNodeIfNeeded() - } - } - } catch { - Logger.error(error, context: "Failed to stop LN") - await MainActor.run { - // End the background task if there was an error - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - backgroundTaskID = .invalid - Logger.debug("Background task ended after error stopping node") - } - } - } - } - } - - func startNodeIfNeeded() { - // If node is stopping, mark that we want to start after it stops - if wallet.nodeLifecycleState == .stopping { - Logger.debug("Node is stopping, will start after it finishes") - pendingStartAfterStop = true - return - } - - // Already running or starting - guard wallet.nodeLifecycleState == .stopped else { - Logger.debug("LN is already running or starting, abandoning restart...") - return - } - - guard scenePhase != .background else { - Logger.debug("Scene phase is background, abandoning node restart...") - return - } - - Logger.debug("App active, starting LN service...") - - Task { - do { - try await wallet.start() - } catch { - Logger.error(error, context: "Failed to start LN") - } - } - } - - /// Removes all delivered notifications from Notification Center - /// The app will handle processing any relevant notifications when it opens - func clearDeliveredNotifications() async { - let center = UNUserNotificationCenter.current() - let deliveredNotifications = await center.deliveredNotifications() - - guard !deliveredNotifications.isEmpty else { return } - - let identifiers = deliveredNotifications.map(\.request.identifier) - center.removeDeliveredNotifications(withIdentifiers: identifiers) - Logger.debug("Removed \(identifiers.count) notification(s) from Notification Center") - } -} - -extension View { - /// Stops and restarts lightning node when the app enters the background and foreground - /// - Returns: View - func handleLightningStateOnScenePhaseChange() -> some View { - modifier(HandleLightningStateOnScenePhaseChange()) - } -}