diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 352a71c11..744ba9852 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -61,27 +61,6 @@ }; /* End PBXCopyFilesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Remove Static Framework Stubs"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Remove static framework stubs from app bundle\\n# LDKNodeFFI is a static library - its code is linked into the main executable.\\n# The empty framework structure causes iOS install errors.\\nFRAMEWORK_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework\"\\n\\nif [ -d \"$FRAMEWORK_PATH\" ]; then\\n echo \"Removing LDKNodeFFI static framework stub...\"\\n rm -rf \"$FRAMEWORK_PATH\"\\n echo \"Done.\"\\nfi\\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXFileReference section */ 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BitkitNotification.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F612C2DE6AA006D0C8B /* Bitkit.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bitkit.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -443,6 +422,27 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 96EMBED0012026012000FRAME /* Remove Static Framework Stubs */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Remove Static Framework Stubs"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Remove static framework stubs from app bundle\\n# LDKNodeFFI is a static library - its code is linked into the main executable.\\n# The empty framework structure causes iOS install errors.\\nFRAMEWORK_PATH=\"${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/Frameworks/LDKNodeFFI.framework\"\\n\\nif [ -d \"$FRAMEWORK_PATH\" ]; then\\n echo \"Removing LDKNodeFFI static framework stub...\"\\n rm -rf \"$FRAMEWORK_PATH\"\\n echo \"Done.\"\\nfi\\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 961058D82C355B5500E1F1D8 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -893,7 +893,7 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/synonymdev/vss-rust-client-ffi"; requirement = { - branch = master; + branch = "master"; kind = branch; }; }; diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd17d19be..840eabd05 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -52,7 +52,7 @@ "location" : "https://github.com/synonymdev/vss-rust-client-ffi", "state" : { "branch" : "master", - "revision" : "3d80852e6439c0b2034765e513e4620bb10b3f14" + "revision" : "9f01c135c7e22e594bb1dee749130b82b505ab5e" } }, { diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 0a144bd02..9ffc02444 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -419,6 +419,7 @@ struct MainNavView: View { // Dev settings case .blocktankRegtest: BlocktankRegtestView() case .ldkDebug: LdkDebugScreen() + case .vssDebug: VssDebugScreen() case .probingTool: ProbingToolScreen() case .orders: ChannelOrders() case .logs: LogView() diff --git a/Bitkit/Services/VssBackupClient.swift b/Bitkit/Services/VssBackupClient.swift index 6d6d7ad74..54f9a10be 100644 --- a/Bitkit/Services/VssBackupClient.swift +++ b/Bitkit/Services/VssBackupClient.swift @@ -56,47 +56,49 @@ class VssBackupClient { static let shared = VssBackupClient() private let setupCoordinator = VssSetupCoordinator() + private let ldkSetupCoordinator = VssSetupCoordinator() private init() {} func reset() async { await setupCoordinator.reset() + await ldkSetupCoordinator.reset() + } + + /// Returns lnurl auth params when lnurl is configured; nil otherwise. + private func getLnurlAuthParams(walletIndex: Int) async throws + -> (vssUrl: String, storeId: String, mnemonic: String, passphrase: String?, lnurlAuthServerUrl: String)? + { + let lnurlAuthServerUrl = Env.lnurlAuthServerUrl + guard !lnurlAuthServerUrl.isEmpty else { return nil } + guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { + throw CustomServiceError.mnemonicNotFound + } + let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) + let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex) + return (Env.vssServerUrl, storeId, mnemonic, passphrase, lnurlAuthServerUrl) } private func setup(walletIndex: Int = 0) async throws { do { - try await withTimeout(seconds: 30) { + try await withTimeout(seconds: 30) { [self] in Logger.debug("VSS client setting up…", context: "VssBackupClient") - let vssUrl = Env.vssServerUrl - let lnurlAuthServerUrl = Env.lnurlAuthServerUrl Logger.debug("Building VSS client with vssUrl: '\(vssUrl)'", context: "VssBackupClient") - Logger.debug("Building VSS client with lnurlAuthServerUrl: '\(lnurlAuthServerUrl)'", context: "VssBackupClient") - - let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex) - - if !lnurlAuthServerUrl.isEmpty { - guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: walletIndex)) else { - throw CustomServiceError.mnemonicNotFound - } - // Normalize empty strings to nil - empty passphrase should be treated as no passphrase - let passphraseRaw = try Keychain.loadString(key: .bip39Passphrase(index: walletIndex)) - let passphrase = passphraseRaw?.isEmpty == true ? nil : passphraseRaw + if let params = try await getLnurlAuthParams(walletIndex: walletIndex) { try await vssNewClientWithLnurlAuth( - baseUrl: vssUrl, - storeId: storeId, - mnemonic: mnemonic, - passphrase: passphrase, - lnurlAuthServerUrl: lnurlAuthServerUrl + baseUrl: params.vssUrl, + storeId: params.storeId, + mnemonic: params.mnemonic, + passphrase: params.passphrase, + lnurlAuthServerUrl: params.lnurlAuthServerUrl ) } else { - try await vssNewClient( - baseUrl: vssUrl, - storeId: storeId - ) + let storeId = try await VssStoreIdProvider.shared.getVssStoreId(walletIndex: walletIndex) + try await vssNewClient(baseUrl: vssUrl, storeId: storeId) } - Logger.info("VSS client setup with server: '\(vssUrl)'", context: "VssBackupClient") } } catch { @@ -105,6 +107,29 @@ class VssBackupClient { } } + /// Lazily initializes the LDK VSS client (used only by the debug screen). Only runs when lnurl auth is configured. + private func setupLdk(walletIndex: Int = 0) async throws { + guard let params = try await getLnurlAuthParams(walletIndex: walletIndex) else { + throw AppError(message: "LDK VSS requires lnurl auth", debugMessage: "lnurlAuthServerUrl is not set") + } + do { + try await withTimeout(seconds: 30) { + Logger.debug("VSS LDK client setting up…", context: "VssBackupClient") + try await vssNewLdkClientWithLnurlAuth( + baseUrl: params.vssUrl, + storeId: params.storeId, + mnemonic: params.mnemonic, + passphrase: params.passphrase, + lnurlAuthServerUrl: params.lnurlAuthServerUrl + ) + Logger.info("VSS LDK client setup with server: '\(params.vssUrl)'", context: "VssBackupClient") + } + } catch { + Logger.error("VSS LDK client setup error: \(error)", context: "VssBackupClient") + throw error + } + } + func putObject(key: String, data: Data) async throws -> VssItem { try await awaitSetup() @@ -126,25 +151,126 @@ class VssBackupClient { Logger.debug("VSS 'getObject' call for '\(key)'", context: "VssBackupClient") do { - let item = try await vssGet(key: key) - if let item { - Logger.debug("VSS 'getObject' success for '\(key)'", context: "VssBackupClient") + if let item = try await vssGet(key: key) { + Logger.debug("VSS 'getObject' success for '\(key)' at version \(item.version)", context: "VssBackupClient") + return item } else { Logger.debug("VSS 'getObject' success null for '\(key)'", context: "VssBackupClient") + return nil } - return item } catch { Logger.debug("VSS 'getObject' error for '\(key)': \(error)", context: "VssBackupClient") throw error } } + func listKeys() async throws -> [String] { + let versions = try await listKeyVersions() + return versions.map(\.key) + } + + /// Returns app-level keys with version info (for debug UI). + func listKeyVersions() async throws -> [KeyVersion] { + try await awaitSetup() + Logger.debug("VSS 'listKeyVersions' call", context: "VssBackupClient") + do { + let versions = try await vssListKeys(prefix: nil) + Logger.debug("VSS 'listKeyVersions' success: \(versions.count) key(s)", context: "VssBackupClient") + return versions + } catch { + Logger.debug("VSS 'listKeyVersions' error: \(error)", context: "VssBackupClient") + throw error + } + } + + /// Deletes a single app-level key. + func deleteKey(_ key: String) async throws -> Bool { + try await awaitSetup() + Logger.debug("VSS 'deleteKey' call for '\(key)'", context: "VssBackupClient") + do { + let wasDeleted = try await vssDelete(key: key) + Logger.debug("VSS 'deleteKey' success for '\(key)': \(wasDeleted)", context: "VssBackupClient") + return wasDeleted + } catch { + Logger.debug("VSS 'deleteKey' error for '\(key)': \(error)", context: "VssBackupClient") + throw error + } + } + + /// Deletes all app-level keys (lists then deletes each). + func deleteAllKeys() async throws { + let versions = try await listKeyVersions() + for kv in versions { + _ = try await deleteKey(kv.key) + } + } + + // MARK: - LDK namespace keys (for debug; requires FFI with LdkNamespace support) + + private static let ldkNamespacesForList: [LdkNamespace] = [ + .default, + .monitors, + .archivedMonitors, + ] + + /// Returns all LDK keys across default, monitors, and archivedMonitors namespaces. + func listAllKeysTaggedLdk() async throws -> [(LdkNamespace, KeyVersion)] { + try await awaitLdkSetup() + Logger.debug("VSS 'listAllKeysTaggedLdk' call", context: "VssBackupClient") + var result: [(LdkNamespace, KeyVersion)] = [] + for ns in Self.ldkNamespacesForList { + do { + let keys = try await vssLdkListKeys(namespace: ns) + result.append(contentsOf: keys.map { (ns, $0) }) + } catch { + Logger.debug("VSS 'listAllKeysTaggedLdk' error for namespace \(ns): \(error)", context: "VssBackupClient") + throw error + } + } + Logger.debug("VSS 'listAllKeysTaggedLdk' success: \(result.count) key(s)", context: "VssBackupClient") + return result + } + + /// Gets a single LDK key value by key and namespace. + func getObjectLdk(key: String, namespace: LdkNamespace) async throws -> VssItem? { + try await awaitLdkSetup() + Logger.debug("VSS 'getObjectLdk' call for '\(key)'", context: "VssBackupClient") + do { + let item = try await vssLdkGet(key: key, namespace: namespace) + return item + } catch { + Logger.debug("VSS 'getObjectLdk' error for '\(key)': \(error)", context: "VssBackupClient") + throw error + } + } + + /// Deletes a single LDK key by key and namespace. + func deleteObjectLdk(key: String, namespace: LdkNamespace) async throws -> Bool { + try await awaitLdkSetup() + Logger.debug("VSS 'deleteObjectLdk' call for '\(key)'", context: "VssBackupClient") + do { + let wasDeleted = try await vssLdkDelete(key: key, namespace: namespace) + Logger.debug("VSS 'deleteObjectLdk' success for '\(key)': \(wasDeleted)", context: "VssBackupClient") + return wasDeleted + } catch { + Logger.debug("VSS 'deleteObjectLdk' error for '\(key)': \(error)", context: "VssBackupClient") + throw error + } + } + private func awaitSetup() async throws { try await setupCoordinator.awaitSetup { [self] in try await setup() } } + /// Lazily sets up the LDK client when first needed (debug screen). Independent of the app client. + private func awaitLdkSetup() async throws { + try await ldkSetupCoordinator.awaitSetup { [self] in + try await setupLdk() + } + } + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { try await withThrowingTaskGroup(of: T.self) { group in group.addTask { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 7b61917bd..d718f8424 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -98,6 +98,7 @@ enum Route: Hashable { // Dev settings case blocktankRegtest case ldkDebug + case vssDebug case probingTool case orders case logs diff --git a/Bitkit/Views/Settings/DevSettings/VssDebugScreen.swift b/Bitkit/Views/Settings/DevSettings/VssDebugScreen.swift new file mode 100644 index 000000000..6ffa73d1b --- /dev/null +++ b/Bitkit/Views/Settings/DevSettings/VssDebugScreen.swift @@ -0,0 +1,385 @@ +import SwiftUI +import VssRustClientFfi + +private enum VssTab: String, CaseIterable, CustomStringConvertible { + case app + case ldk + + var description: String { + switch self { + case .app: return "App" + case .ldk: return "LDK" + } + } +} + +struct VssLdkKeyItem: Identifiable { + let id = UUID() + let keyVersion: KeyVersion + let namespace: LdkNamespace +} + +private struct ShareableFileList: Identifiable { + let id = UUID() + let urls: [URL] +} + +struct VssDebugScreen: View { + @EnvironmentObject var app: AppViewModel + + @State private var selectedTab: VssTab = .app + @State private var appKeys: [KeyVersion] = [] + @State private var ldkKeys: [VssLdkKeyItem] = [] + @State private var isLoading = false + @State private var showDeleteAllConfirmation = false + @State private var shareableFileList: ShareableFileList? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar( + title: "VSS Debug", + action: AnyView(Button(action: { + Task { await loadKeysForCurrentTab() } + }) { + Image("arrows-clockwise") + .resizable() + .foregroundColor(isLoading ? .secondary : .textPrimary) + .frame(width: 24, height: 24) + }) + ) + .padding(.bottom, 16) + + SegmentedControl(selectedTab: $selectedTab, tabs: VssTab.allCases) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + switch selectedTab { + case .app: + appSection + case .ldk: + ldkSection + } + } + } + .refreshable { + await loadKeysForCurrentTab() + } + } + .navigationBarHidden(true) + .padding(.horizontal, 16) + .bottomSafeAreaPadding() + .alert( + "Delete all keys?", + isPresented: $showDeleteAllConfirmation, + actions: { + Button(t("common__cancel"), role: .cancel) { + showDeleteAllConfirmation = false + } + Button(t("common__delete_yes"), role: .destructive) { + Task { await deleteAllAppKeys() } + showDeleteAllConfirmation = false + } + }, + message: { + Text("This will remove all app-level VSS keys. LDK keys are not affected.") + } + ) + .task(id: selectedTab) { + await loadKeysForCurrentTab() + } + .sheet(item: $shareableFileList, onDismiss: { + if let list = shareableFileList { + for url in list.urls { + try? FileManager.default.removeItem(at: url) + } + if let dir = list.urls.first?.deletingLastPathComponent() { + try? FileManager.default.removeItem(at: dir) + } + } + shareableFileList = nil + }) { item in + ShareSheet(activityItems: item.urls) + } + } + + /// Loads keys for the currently selected tab (used on appear, tab change, and refresh). + @MainActor + private func loadKeysForCurrentTab() async { + switch selectedTab { + case .app: + await listAppKeys() + case .ldk: + await listLdkKeys() + } + } + + // MARK: - App tab + + private var appSection: some View { + VStack(alignment: .leading, spacing: 16) { + if !appKeys.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(appKeys, id: \.key) { keyVersion in + appKeyRow(keyVersion: keyVersion) + } + } + + HStack(spacing: 8) { + CustomButton( + title: "Export", + variant: .secondary, + size: .small, + icon: Image(systemName: "square.and.arrow.up"), + isDisabled: isLoading + ) { + Task { await exportAllAppKeys() } + } + CustomButton( + title: "Delete All", + variant: .secondary, + size: .small, + icon: Image("trash") + .resizable() + .frame(width: 16, height: 16), + isLoading: isLoading + ) { + showDeleteAllConfirmation = true + } + } + } + } + } + + private func appKeyRow(keyVersion: KeyVersion) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + SubtitleText(keyVersion.key) + .lineLimit(1) + .truncationMode(.middle) + FootnoteText("v\(keyVersion.version)") + } + Spacer(minLength: 8) + Button { + Task { await deleteAppKey(keyVersion.key) } + } label: { + Image("trash") + .foregroundColor(.redAccent) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isLoading) + .accessibilityLabel("Delete key \(keyVersion.key)") + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.white08) + .cornerRadius(8) + } + + // MARK: - LDK tab + + private var ldkSection: some View { + VStack(alignment: .leading, spacing: 16) { + if !ldkKeys.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(ldkKeys) { item in + ldkKeyRow(item: item) + } + } + + CustomButton( + title: "Export", + variant: .secondary, + size: .small, + icon: Image(systemName: "square.and.arrow.up"), + isDisabled: isLoading + ) { + Task { await exportAllLdkKeys() } + } + } + } + } + + private func ldkKeyRow(item: VssLdkKeyItem) -> some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + SubtitleText(item.keyVersion.key) + .lineLimit(1) + .truncationMode(.middle) + FootnoteText(ldkNamespaceLabel(item.namespace) + " (v\(item.keyVersion.version))") + } + Spacer(minLength: 8) + Button { + Task { await deleteLdkKey(item) } + } label: { + Image("trash") + .foregroundColor(.redAccent) + } + .buttonStyle(PlainButtonStyle()) + .disabled(isLoading) + .accessibilityLabel("Delete key \(item.keyVersion.key)") + } + .padding(.vertical, 8) + .padding(.horizontal, 16) + .background(Color.white08) + .cornerRadius(8) + } + + private func ldkNamespaceLabel(_ namespace: LdkNamespace) -> String { + switch namespace { + case .default: return "default" + case .monitors: return "monitors" + case let .monitorUpdates(monitorId): return "monitorUpdates(\(monitorId))" + case .archivedMonitors: return "archivedMonitors" + } + } + + private func sanitizedFilename(from key: String) -> String { + key.replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + } + + /// Writes export files to a temp directory and returns their URLs. + private func writeExportFiles(_ files: [(name: String, data: Data)], subdirectory: String = "vss_exports") throws -> [URL] { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return try files.map { name, data in + let url = tempDir.appendingPathComponent(name) + try data.write(to: url) + return url + } + } + + // MARK: - Actions + + @MainActor + private func listAppKeys() async { + isLoading = true + defer { isLoading = false } + do { + appKeys = try await VssBackupClient.shared.listKeyVersions() + } catch { + Logger.error("VSS list app keys failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to list keys", description: error.localizedDescription) + } + } + + @MainActor + private func deleteAppKey(_ key: String) async { + isLoading = true + defer { isLoading = false } + do { + let wasDeleted = try await VssBackupClient.shared.deleteKey(key) + if wasDeleted { + appKeys.removeAll { $0.key == key } + app.toast(type: .success, title: "Deleted key: \(key)", description: "The app key was removed from VSS.") + } else { + app.toast(type: .warning, title: "Key not found: \(key)", description: "The key may have been deleted already.") + } + } catch { + Logger.error("VSS delete app key failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to delete key", description: error.localizedDescription) + } + } + + @MainActor + private func deleteAllAppKeys() async { + showDeleteAllConfirmation = false + isLoading = true + defer { isLoading = false } + do { + try await VssBackupClient.shared.deleteAllKeys() + appKeys = [] + app.toast(type: .success, title: "All app keys deleted", description: "All app-level keys were removed from VSS.") + } catch { + Logger.error("VSS delete all app keys failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to delete all keys", description: error.localizedDescription) + } + } + + @MainActor + private func listLdkKeys() async { + isLoading = true + defer { isLoading = false } + do { + let tagged = try await VssBackupClient.shared.listAllKeysTaggedLdk() + ldkKeys = tagged.map { VssLdkKeyItem(keyVersion: $0.1, namespace: $0.0) } + } catch { + Logger.error("VSS list LDK keys failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to list LDK keys", description: error.localizedDescription) + } + } + + @MainActor + private func deleteLdkKey(_ item: VssLdkKeyItem) async { + isLoading = true + defer { isLoading = false } + do { + let wasDeleted = try await VssBackupClient.shared.deleteObjectLdk(key: item.keyVersion.key, namespace: item.namespace) + if wasDeleted { + ldkKeys.removeAll { $0.id == item.id } + app.toast(type: .success, title: "Deleted LDK key: \(item.keyVersion.key)", description: "The LDK key was removed from VSS.") + } else { + app.toast(type: .warning, title: "Key not found: \(item.keyVersion.key)", description: "The key may have been deleted already.") + } + } catch { + Logger.error("VSS delete LDK key failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to delete LDK key", description: error.localizedDescription) + } + } + + @MainActor + private func exportAllAppKeys() async { + isLoading = true + defer { isLoading = false } + do { + let keys = try await VssBackupClient.shared.listKeyVersions() + if keys.isEmpty { + app.toast(type: .info, title: "No keys to export", description: "There are no app keys to export.") + return + } + var files: [(name: String, data: Data)] = [] + for kv in keys { + guard let item = try await VssBackupClient.shared.getObject(key: kv.key) else { continue } + files.append(("vss_app_\(sanitizedFilename(from: kv.key))", item.value)) + } + if files.isEmpty { + app.toast(type: .warning, title: "No key data", description: "No key values could be read.") + return + } + let urls = try writeExportFiles(files) + shareableFileList = ShareableFileList(urls: urls) + } catch { + Logger.error("VSS export all app keys failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to export keys", description: error.localizedDescription) + } + } + + @MainActor + private func exportAllLdkKeys() async { + isLoading = true + defer { isLoading = false } + do { + if ldkKeys.isEmpty { + app.toast(type: .info, title: "No keys to export", description: "List keys first, then export all.") + return + } + var files: [(name: String, data: Data)] = [] + for item in ldkKeys { + guard let vssItem = try await VssBackupClient.shared.getObjectLdk(key: item.keyVersion.key, namespace: item.namespace) + else { continue } + let namespaceLabel = sanitizedFilename(from: ldkNamespaceLabel(item.namespace)) + files.append(("vss_ldk_\(namespaceLabel)_\(sanitizedFilename(from: item.keyVersion.key))", vssItem.value)) + } + if files.isEmpty { + app.toast(type: .warning, title: "No key data", description: "No LDK key values could be read.") + return + } + let urls = try writeExportFiles(files) + shareableFileList = ShareableFileList(urls: urls) + } catch { + Logger.error("VSS export all LDK keys failed: \(error)", context: "VssDebugScreen") + app.toast(type: .error, title: "Failed to export LDK keys", description: error.localizedDescription) + } + } +} diff --git a/Bitkit/Views/Settings/DevSettingsView.swift b/Bitkit/Views/Settings/DevSettingsView.swift index 4cf433415..4326198f3 100644 --- a/Bitkit/Views/Settings/DevSettingsView.swift +++ b/Bitkit/Views/Settings/DevSettingsView.swift @@ -24,6 +24,10 @@ struct DevSettingsView: View { SettingsListLabel(title: "LDK") } + NavigationLink(value: Route.vssDebug) { + SettingsListLabel(title: "VSS") + } + NavigationLink(value: Route.probingTool) { SettingsListLabel(title: "Probing Tool") }