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
1 change: 1 addition & 0 deletions Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
Services/GeoService.swift,
Services/LightningService.swift,
Services/MigrationsService.swift,
Services/RNBackupClient.swift,
Services/ServiceQueue.swift,
Services/VssStoreIdProvider.swift,
Utilities/Crypto.swift,
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

123 changes: 119 additions & 4 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct AppScene: View {
@StateObject private var tagManager = TagManager()
@StateObject private var transferTracking: TransferTrackingManager
@StateObject private var channelDetails = ChannelDetailsViewModel.shared
@StateObject private var migrations = MigrationsService.shared

@State private var hideSplash = false
@State private var removeSplash = false
Expand Down Expand Up @@ -72,6 +73,14 @@ struct AppScene: View {
.onChange(of: wallet.walletExists, perform: handleWalletExistsChange)
.onChange(of: wallet.nodeLifecycleState, perform: handleNodeLifecycleChange)
.onChange(of: scenePhase, perform: handleScenePhaseChange)
.onChange(of: migrations.isShowingMigrationLoading) { isLoading in
if !isLoading {
widgets.loadSavedWidgets()
if UserDefaults.standard.bool(forKey: "pinOnLaunch") && settings.pinEnabled {
isPinVerified = false
}
}
}
.environmentObject(app)
.environmentObject(navigation)
.environmentObject(network)
Expand Down Expand Up @@ -111,7 +120,9 @@ struct AppScene: View {
@ViewBuilder
private var mainContent: some View {
ZStack {
if showRecoveryScreen {
if migrations.isShowingMigrationLoading {
migrationLoadingContent
} else if showRecoveryScreen {
RecoveryRouter()
.accentColor(.white)
} else if hasCriticalUpdate {
Expand All @@ -127,6 +138,32 @@ struct AppScene: View {
}
}

@ViewBuilder
private var migrationLoadingContent: some View {
VStack(spacing: 24) {
Spacer()

ProgressView()
.scaleEffect(1.5)
.tint(.white)

VStack(spacing: 8) {
Text("Updating Wallet")
.font(.system(size: 24, weight: .semibold))
.foregroundColor(.white)

Text("Please wait while we update the app...")
.font(.system(size: 16))
.foregroundColor(.white.opacity(0.7))
.multilineTextAlignment(.center)
}

Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black)
}

@ViewBuilder
private var walletContent: some View {
if wallet.walletExists == true {
Expand Down Expand Up @@ -216,16 +253,18 @@ struct AppScene: View {

if wallet.isRestoringWallet {
Task {
await BackupService.shared.performFullRestoreFromLatestBackup()
await restoreFromMostRecentBackup()

await MainActor.run {
widgets.loadSavedWidgets()
widgets.objectWillChange.send()
}

await startWallet()
}
} else {
Task { await startWallet() }
}

Task { await startWallet() }
}

private func startWallet() async {
Expand All @@ -247,6 +286,7 @@ struct AppScene: View {
@Sendable
private func setupTask() async {
do {
await checkAndPerformRNMigration()
try wallet.setWalletExistsState()

// Setup TimedSheetManager with all timed sheets
Expand All @@ -262,6 +302,81 @@ struct AppScene: View {
}
}

private func checkAndPerformRNMigration() async {
let migrations = MigrationsService.shared

guard !migrations.isMigrationChecked else {
Logger.debug("RN migration already checked, skipping", context: "AppScene")
return
}

guard !migrations.hasNativeWalletData() else {
Logger.info("Native wallet data exists, skipping RN migration", context: "AppScene")
migrations.markMigrationChecked()
return
}

guard migrations.hasRNWalletData() else {
Logger.info("No RN wallet data found, skipping migration", context: "AppScene")
migrations.markMigrationChecked()
return
}

await MainActor.run { migrations.isShowingMigrationLoading = true }
Logger.info("RN wallet data found, starting migration...", context: "AppScene")

do {
try await migrations.migrateFromReactNative()
} catch {
Logger.error("RN migration failed: \(error)", context: "AppScene")
migrations.markMigrationChecked()
await MainActor.run { migrations.isShowingMigrationLoading = false }
app.toast(
type: .error,
title: "Migration Failed",
description: "Please restore your wallet manually using your recovery phrase"
)
}
}

private func restoreFromMostRecentBackup() async {
guard let mnemonicData = try? Keychain.load(key: .bip39Mnemonic(index: 0)),
let mnemonic = String(data: mnemonicData, encoding: .utf8)
else { return }

let passphrase: String? = {
guard let data = try? Keychain.load(key: .bip39Passphrase(index: 0)) else { return nil }
return String(data: data, encoding: .utf8)
}()

// Check for RN backup and get its timestamp
let hasRNBackup = await MigrationsService.shared.hasRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase)
let rnTimestamp: UInt64? = await hasRNBackup ? (try? RNBackupClient.shared.getLatestBackupTimestamp()) : nil

// Get VSS backup timestamp
let vssTimestamp = await BackupService.shared.getLatestBackupTime()

// Determine which backup is more recent
let shouldRestoreRN: Bool = {
guard hasRNBackup else { return false }
guard let vss = vssTimestamp, vss > 0 else { return true } // No VSS, use RN
guard let rn = rnTimestamp else { return false } // No RN timestamp, use VSS
return rn >= vss // RN is same or newer
}()

if shouldRestoreRN {
do {
try await MigrationsService.shared.restoreFromRNRemoteBackup(mnemonic: mnemonic, passphrase: passphrase)
} catch {
Logger.error("RN remote backup restore failed: \(error)", context: "AppScene")
// Fall back to VSS
await BackupService.shared.performFullRestoreFromLatestBackup()
}
} else {
await BackupService.shared.performFullRestoreFromLatestBackup()
}
}

private func handleNodeLifecycleChange(_ state: NodeLifecycleState) {
if state == .initializing {
walletIsInitializing = true
Expand Down
14 changes: 14 additions & 0 deletions Bitkit/Constants/Env.swift
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,20 @@ enum Env {
}
}

static var rnBackupServerHost: String {
switch network {
case .bitcoin: "https://blocktank.synonym.to/backups-ldk"
default: "https://bitkit.stag0.blocktank.to/backups-ldk"
}
}

static var rnBackupServerPubKey: String {
switch network {
case .bitcoin: "0236efd76e37f96cf2dced9d52ff84c97e5b3d4a75e7d494807291971783f38377"
default: "02c03b8b8c1b5500b622646867d99bf91676fac0f38e2182c91a9ff0d053a21d6d"
}
}

static var blockExplorerUrl: String {
switch network {
case .bitcoin: "https://mempool.space"
Expand Down
63 changes: 51 additions & 12 deletions Bitkit/Services/BackupService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -499,14 +499,53 @@ class BackupService {
return statuses[category] ?? BackupItemStatus()
}

func getLatestBackupTime() -> UInt64? {
let statuses = getAllBackupStatuses()
let syncedTimestamps = BackupCategory.allCases.compactMap { category -> UInt64? in
let status = statuses[category] ?? BackupItemStatus()
return status.synced > 0 ? status.synced : nil
func getLatestBackupTime() async -> UInt64? {
do {
try await vssBackupClient.setup()

let timestamps = await withTaskGroup(of: UInt64?.self) { group in
for category in BackupCategory.allCases where category != .lightningConnections {
group.addTask {
await self.getRemoteBackupTimestamp(category: category)
}
}

var results: [UInt64] = []
for await timestamp in group {
if let ts = timestamp, ts > 0 {
results.append(ts)
}
}
return results
}

return timestamps.max()
} catch {
Logger.warn("Failed to get VSS backup timestamp: \(error)", context: "BackupService")
return nil
}
}

private func getRemoteBackupTimestamp(category: BackupCategory) async -> UInt64? {
do {
guard let item = try await vssBackupClient.getObject(key: category.rawValue) else {
return nil
}

return syncedTimestamps.max()
struct BackupWithCreatedAt: Codable {
let createdAt: UInt64?
}

let backup = try JSONDecoder().decode(BackupWithCreatedAt.self, from: item.value)
guard let createdAtMillis = backup.createdAt, createdAtMillis > 0 else {
return nil
}
// Convert from milliseconds to seconds (matching Android behavior)
return createdAtMillis / 1000
} catch {
Logger.debug("Failed to get remote backup timestamp for \(category.rawValue): \(error)", context: "BackupService")
return nil
}
}

func scheduleFullBackup() async {
Expand Down Expand Up @@ -576,7 +615,7 @@ class BackupService {
let settingsDict = await SettingsViewModel.shared.getSettingsDictionary()
let payload = SettingsBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
settings: settingsDict
)
return try payload.encode()
Expand All @@ -592,7 +631,7 @@ class BackupService {

let payload = WidgetsBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
widgets: androidWidgetsDict
)
let encoded = try payload.encode()
Expand All @@ -603,13 +642,13 @@ class BackupService {
let transfers = try TransferStorage.shared.getAll()
let payload = WalletBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
transfers: transfers
)
return try JSONEncoder().encode(payload)

case .metadata:
let currentTime = UInt64(Date().timeIntervalSince1970)
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
let cache = await SettingsViewModel.shared.getAppCacheData()

let preActivityMetadata = try await CoreService.shared.activity.getAllPreActivityMetadata()
Expand All @@ -629,7 +668,7 @@ class BackupService {

let payload = BlocktankBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
orders: orders,
cjitEntries: cjitEntries,
info: info
Expand All @@ -644,7 +683,7 @@ class BackupService {

let payload = ActivityBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970),
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
activities: activities,
activityTags: activityTags,
closedChannels: closedChannels
Expand Down
Loading
Loading