diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index c6b21f21..98717819 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -7,27 +7,24 @@ objects = { /* Begin PBXBuildFile section */ - 18D65E002EB964B500252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; 18D65E022EB964BD00252335 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65E012EB964BD00252335 /* VssRustClientFfi */; }; - 4AAB08CA2E1FE77600BA63DF /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; - 4AFCA3702E05933800205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA36F2E05933800205CAE /* Zip */; }; + 353338626165333561643362 /* PubkyNoise.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 313438323636356161626437 /* PubkyNoise.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 4AFCA3722E0596D900205CAE /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA3712E0596D900205CAE /* Zip */; }; - 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 961058DC2C355B5500E1F1D8 /* BitkitNotification.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 524EFA4E2EF1CBF900BA8372 /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; + 524EFA4F2EF1CBF900BA8372 /* LightningDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = 966DE66F2C51210000A7B0EF /* LightningDevKit */; }; + 524EFA502EF1CBF900BA8372 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A32C942FD1000E8BC2 /* secp256k1 */; }; + 524EFA512EF1CBF900BA8372 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; }; + 524EFA522EF1CBF900BA8372 /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; + 524EFA532EF1CBF900BA8372 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 96204B772DE9AA43007BAA26 /* SQLite */; }; + 524EFA542EF1CBF900BA8372 /* Zip in Frameworks */ = {isa = PBXBuildFile; productRef = 4AFCA36F2E05933800205CAE /* Zip */; }; + 524EFA552EF1CBF900BA8372 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 4AAB08C92E1FE77600BA63DF /* Lottie */; }; + 524EFA562EF1CBF900BA8372 /* VssRustClientFfi in Frameworks */ = {isa = PBXBuildFile; productRef = 18D65DFF2EB964B500252335 /* VssRustClientFfi */; }; + 653434323064303830666662 /* PaykitMobile.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 356364643963336664373536 /* PaykitMobile.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 96204B762DE9A91A007BAA26 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 96204B752DE9A91A007BAA26 /* SQLite */; }; - 96204B782DE9AA43007BAA26 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 96204B772DE9AA43007BAA26 /* SQLite */; }; - 966DE6702C51210000A7B0EF /* LightningDevKit in Frameworks */ = {isa = PBXBuildFile; productRef = 966DE66F2C51210000A7B0EF /* LightningDevKit */; }; - 968FDF162DFAFE230053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 9613018B2C5022D700878183 /* LDKNode */; }; 968FE1402DFB016B0053CD7F /* LDKNode in Frameworks */ = {isa = PBXBuildFile; productRef = 968FE13F2DFB016B0053CD7F /* LDKNode */; }; - 96DEA03A2DE8BBA1009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA0392DE8BBA1009932BF /* BitkitCore */; }; 96DEA03C2DE8BBAB009932BF /* BitkitCore in Frameworks */ = {isa = PBXBuildFile; productRef = 96DEA03B2DE8BBAB009932BF /* BitkitCore */; }; - 96E20CD42CB6D91A00C24149 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 96E20CD32CB6D91A00C24149 /* CodeScanner */; }; - 96E493A42C942FD1000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A32C942FD1000E8BC2 /* secp256k1 */; }; 96E493A62C94317D000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A52C94317D000E8BC2 /* secp256k1 */; }; 96E493A82C943184000E8BC2 /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = 96E493A72C943184000E8BC2 /* secp256k1 */; }; - 653535353063363831356463 /* PaykitMobile.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 356364643963336664373536 /* PaykitMobile.xcframework */; }; - 393238643938643431663963 /* PubkyNoise.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313438323636356161626437 /* PubkyNoise.xcframework */; }; - 653434323064303830666662 /* PaykitMobile.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 356364643963336664373536 /* PaykitMobile.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 353338626165333561643362 /* PubkyNoise.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 313438323636356161626437 /* PubkyNoise.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,57 +52,42 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 961058E72C355B5500E1F1D8 /* Embed Foundation Extensions */ = { + 663033386161356430326664 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; - dstSubfolderSpec = 13; + dstSubfolderSpec = 10; files = ( - 961058E32C355B5500E1F1D8 /* BitkitNotification.appex in Embed Foundation Extensions */, + 653434323064303830666662 /* PaykitMobile.xcframework in Embed Frameworks */, + 353338626165333561643362 /* PubkyNoise.xcframework in Embed Frameworks */, ); - name = "Embed Foundation Extensions"; + name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 663033386161356430326664 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - 653434323064303830666662 /* PaykitMobile.xcframework in Embed Frameworks */, - 353338626165333561643362 /* PubkyNoise.xcframework in Embed Frameworks */, - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 313438323636356161626437 /* PubkyNoise.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = PubkyNoise.xcframework; path = Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework; sourceTree = SOURCE_ROOT; }; + 356364643963336664373536 /* PaykitMobile.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = PaykitMobile.xcframework; path = Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework; sourceTree = SOURCE_ROOT; }; 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; }; 96FE1F722C2DE6AC006D0C8B /* BitkitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 96FE1F7C2C2DE6AC006D0C8B /* BitkitUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BitkitUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 356364643963336664373536 /* PaykitMobile.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = PaykitMobile.xcframework; path = Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework; sourceTree = SOURCE_ROOT; }; - 313438323636356161626437 /* PubkyNoise.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = PubkyNoise.xcframework; path = Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 96A44F3D2CEF5EA700FBACFF /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + AppDelegate_integration.swift, Info.plist, + PaykitIntegration/BUILD_CONFIGURATION.md, + PaykitIntegration/FFI/PubkyNoise.swift, PipBackgroundHandler.swift, PipSDK.swift, PipSessionStore.swift, - "Views/Wallets/Receive/PipReceiveView.swift", - "AppDelegate_integration.swift", - "PaykitIntegration/BUILD_CONFIGURATION.md", - "PaykitIntegration/FFI/PubkyNoise.swift", - "Views/Settings/PaymentProfileView.swift", - "Views/Paykit/ContactDiscoveryView.swift", - "Views/Paykit/PaykitAutoPayView.swift", - "Views/Paykit/PaykitPaymentRequestsView.swift", - "Views/Paykit/NoisePaymentView.swift", + Views/Settings/PaymentProfileView.swift, + Views/Wallets/Receive/PipReceiveView.swift, ); target = 96FE1F602C2DE6AA006D0C8B /* Bitkit */; }; @@ -188,8 +170,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 653535353063363831356463 /* PaykitMobile.xcframework in Frameworks */, - 393238643938643431663963 /* PubkyNoise.xcframework in Frameworks */, + 524EFA562EF1CBF900BA8372 /* VssRustClientFfi in Frameworks */, + 524EFA552EF1CBF900BA8372 /* Lottie in Frameworks */, + 524EFA542EF1CBF900BA8372 /* Zip in Frameworks */, + 524EFA532EF1CBF900BA8372 /* SQLite in Frameworks */, + 524EFA522EF1CBF900BA8372 /* BitkitCore in Frameworks */, + 524EFA512EF1CBF900BA8372 /* CodeScanner in Frameworks */, + 524EFA502EF1CBF900BA8372 /* secp256k1 in Frameworks */, + 524EFA4F2EF1CBF900BA8372 /* LightningDevKit in Frameworks */, + 524EFA4E2EF1CBF900BA8372 /* LDKNode in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -358,7 +347,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1540; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 2610; TargetAttributes = { 961058DB2C355B5500E1F1D8 = { CreatedOnToolsVersion = 15.4; @@ -509,7 +498,6 @@ CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitNotification; @@ -537,7 +525,6 @@ CODE_SIGN_ENTITLEMENTS = BitkitNotification/BitkitNotification.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = BitkitNotification/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = BitkitNotification; @@ -565,6 +552,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -594,7 +582,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = KYH47R284B; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -616,6 +606,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG CHECK_GEOBLOCK $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -626,6 +617,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -655,7 +647,9 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = KYH47R284B; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -670,6 +664,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "CHECK_GEOBLOCK $(inherited)"; SWIFT_COMPILATION_MODE = wholemodule; }; @@ -683,11 +678,17 @@ CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; - DEVELOPMENT_TEAM = KYH47R284B; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64_x86_64-simulator/Headers", + "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator/Headers", + ); INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -706,30 +707,23 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit-regtest"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; - SWIFT_EMIT_LOC_STRINGS = YES; LIBRARY_SEARCH_PATHS = ( "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64_x86_64-simulator", "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator", ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-lpaykit_mobile", "-lpubky_noise", ); - SWIFT_INCLUDE_PATHS = ( - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/FFI", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64_x86_64-simulator", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator", - ); - HEADER_SEARCH_PATHS = ( - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64_x86_64-simulator/Headers", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator/Headers", - ); + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit-regtest"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/Bitkit/PaykitIntegration/FFI $(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64_x86_64-simulator $(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator"; SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Bitkit-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -744,11 +738,17 @@ CODE_SIGN_ENTITLEMENTS = Bitkit/Bitkit.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Bitkit/Preview Content\""; - DEVELOPMENT_TEAM = KYH47R284B; + ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; + ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + HEADER_SEARCH_PATHS = ( + "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64/Headers", + "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64/Headers", + ); INFOPLIST_FILE = Bitkit/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Bitkit; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; @@ -767,30 +767,23 @@ IPHONEOS_DEPLOYMENT_TARGET = 16.6; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit-regtest"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; - SWIFT_EMIT_LOC_STRINGS = YES; LIBRARY_SEARCH_PATHS = ( "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64", "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64", ); + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; OTHER_LDFLAGS = ( "-lpaykit_mobile", "-lpubky_noise", ); - SWIFT_INCLUDE_PATHS = ( - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/FFI", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64", - ); - HEADER_SEARCH_PATHS = ( - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64/Headers", - "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64/Headers", - ); + PRODUCT_BUNDLE_IDENTIFIER = "to.bitkit-regtest"; + PRODUCT_NAME = "$(TARGET_NAME)"; + REGISTER_APP_GROUPS = YES; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INCLUDE_PATHS = "$(PROJECT_DIR)/Bitkit/PaykitIntegration/FFI $(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PaykitMobile.xcframework/ios-arm64 $(PROJECT_DIR)/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64"; SWIFT_OBJC_BRIDGING_HEADER = "$(PROJECT_DIR)/Bitkit/PaykitIntegration/Bitkit-Bridging-Header.h"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -803,7 +796,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.5; MACOSX_DEPLOYMENT_TARGET = 14.4; @@ -826,7 +819,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.5; MACOSX_DEPLOYMENT_TARGET = 14.4; @@ -847,7 +840,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.5; MACOSX_DEPLOYMENT_TARGET = 14.4; @@ -868,7 +861,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = KYH47R284B; + DEAD_CODE_STRIPPING = YES; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.5; MACOSX_DEPLOYMENT_TARGET = 14.4; diff --git a/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme b/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme index 2d8f9441..ddd8855e 100644 --- a/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme +++ b/Bitkit.xcodeproj/xcshareddata/xcschemes/Bitkit.xcscheme @@ -1,6 +1,6 @@ development com.apple.developer.aps-environment development - com.apple.security.app-sandbox - com.apple.security.application-groups group.bitkit - com.apple.security.files.user-selected.read-only - keychain-access-groups $(AppIdentifierPrefix)to.bitkit diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift index 7218f9ca..1d245ff8 100644 --- a/Bitkit/BitkitApp.swift +++ b/Bitkit/BitkitApp.swift @@ -50,6 +50,17 @@ class AppDelegate: NSObject, UIApplicationDelegate { func applicationWillTerminate(_ application: UIApplication) { try? StateLocker.unlock(.lightning) } + + // MARK: - URL Handling + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + // Route bitkit:// URLs to PubkyRingBridge for Paykit/Pubky-ring callbacks + if url.scheme == "bitkit" { + Logger.info("AppDelegate: Received bitkit:// URL: \(url.absoluteString)", context: "AppDelegate") + return PubkyRingBridge.shared.handleCallback(url: url) + } + return false + } } // MARK: - Push Notifications diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 2293cf4f..fdbc3fe8 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -408,6 +408,7 @@ struct MainNavView: View { case .paykitNoisePayment: NoisePaymentView() case .paykitPrivateEndpoints: PrivateEndpointsView() case .paykitRotationSettings: RotationSettingsView() + case .paykitSessionManagement: SessionManagementView() case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() case .rgsSettings: RgsSettingsScreen() diff --git a/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/Info.plist b/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/Info.plist index 406f426e..1eea0a44 100644 --- a/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/Info.plist +++ b/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/Info.plist @@ -22,13 +22,13 @@ BinaryPath - universal-sim-libpubky_noise.a + libpubky_noise.a HeadersPath Headers LibraryIdentifier ios-arm64_x86_64-simulator LibraryPath - universal-sim-libpubky_noise.a + libpubky_noise.a SupportedArchitectures arm64 diff --git a/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator/universal-sim-libpubky_noise.a b/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator/universal-sim-libpubky_noise.a deleted file mode 100644 index 7761ee71..00000000 Binary files a/Bitkit/PaykitIntegration/Frameworks/PubkyNoise.xcframework/ios-arm64_x86_64-simulator/universal-sim-libpubky_noise.a and /dev/null differ diff --git a/Bitkit/PaykitIntegration/Models/PaymentRequest.swift b/Bitkit/PaykitIntegration/Models/PaymentRequest.swift index a9538a3f..45a7ecb2 100644 --- a/Bitkit/PaykitIntegration/Models/PaymentRequest.swift +++ b/Bitkit/PaykitIntegration/Models/PaymentRequest.swift @@ -32,7 +32,7 @@ public struct BitkitPaymentRequest: Identifiable, Codable { } /// Status of a payment request -public enum PaymentRequestStatus: String, Codable { +public enum PaymentRequestStatus: String, Codable, CaseIterable { case pending = "Pending" case accepted = "Accepted" case declined = "Declined" diff --git a/Bitkit/PaykitIntegration/PaykitManager.swift b/Bitkit/PaykitIntegration/PaykitManager.swift index 0062efc4..b4d17db5 100644 --- a/Bitkit/PaykitIntegration/PaykitManager.swift +++ b/Bitkit/PaykitIntegration/PaykitManager.swift @@ -69,6 +69,11 @@ public final class PaykitManager { lightningNetwork: lightningNetwork.toFfi() ) + // Restore persisted sessions and configure SDK + PubkyRingBridge.shared.restoreSessions() + PubkySDKService.shared.restoreSessions() + PubkySDKService.shared.configure() + isInitialized = true Logger.info("PaykitManager initialized successfully", context: "PaykitManager") } diff --git a/Bitkit/PaykitIntegration/Services/DirectoryService.swift b/Bitkit/PaykitIntegration/Services/DirectoryService.swift index bc9e370a..07776a0f 100644 --- a/Bitkit/PaykitIntegration/Services/DirectoryService.swift +++ b/Bitkit/PaykitIntegration/Services/DirectoryService.swift @@ -204,7 +204,40 @@ public final class DirectoryService { // MARK: - Profile Operations /// Fetch profile for a pubkey from Pubky directory + /// Uses PubkySDKService first, falls back to direct FFI if unavailable public func fetchProfile(for pubkey: String) async throws -> PubkyProfile? { + // Try PubkySDKService first (preferred, direct homeserver access) + do { + let sdkProfile = try await PubkySDKService.shared.fetchProfile(pubkey: pubkey) + // Convert to local PubkyProfile type + return PubkyProfile( + name: sdkProfile.name, + bio: sdkProfile.bio, + avatar: sdkProfile.image, + links: sdkProfile.links?.map { PubkyProfileLink(title: $0.title, url: $0.url) } + ) + } catch { + Logger.debug("PubkySDKService profile fetch failed: \(error)", context: "DirectoryService") + } + + // Try PubkyRingBridge if Pubky-ring is installed (user interaction required) + if PubkyRingBridge.shared.isPubkyRingInstalled { + do { + if let profile = try await PubkyRingBridge.shared.requestProfile(pubkey: pubkey) { + Logger.debug("Got profile from Pubky-ring", context: "DirectoryService") + return profile + } + } catch { + Logger.debug("PubkyRingBridge profile fetch failed: \(error)", context: "DirectoryService") + } + } + + // Fallback to direct FFI + return try await fetchProfileViaFFI(for: pubkey) + } + + /// Fetch profile using direct FFI (fallback) + private func fetchProfileViaFFI(for pubkey: String) async throws -> PubkyProfile? { let adapter = unauthenticatedTransport ?? { let adapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: homeserverBaseURL) let transport = UnauthenticatedTransportFfi.fromCallback(callback: adapter) @@ -261,11 +294,38 @@ public final class DirectoryService { // MARK: - Follows Operations /// Fetch list of pubkeys user follows + /// Uses PubkySDKService first, falls back to direct FFI if unavailable public func fetchFollows() async throws -> [String] { guard let ownerPubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() else { return [] } + // Try PubkySDKService first (preferred, direct homeserver access) + do { + return try await PubkySDKService.shared.fetchFollows(pubkey: ownerPubkey) + } catch { + Logger.debug("PubkySDKService follows fetch failed: \(error)", context: "DirectoryService") + } + + // Try PubkyRingBridge if Pubky-ring is installed (user interaction required) + if PubkyRingBridge.shared.isPubkyRingInstalled { + do { + let follows = try await PubkyRingBridge.shared.requestFollows() + if !follows.isEmpty { + Logger.debug("Got \(follows.count) follows from Pubky-ring", context: "DirectoryService") + return follows + } + } catch { + Logger.debug("PubkyRingBridge follows fetch failed: \(error)", context: "DirectoryService") + } + } + + // Fallback to direct FFI + return try await fetchFollowsViaFFI(ownerPubkey: ownerPubkey) + } + + /// Fetch follows using direct FFI (fallback) + private func fetchFollowsViaFFI(ownerPubkey: String) async throws -> [String] { let adapter = unauthenticatedTransport ?? { let adapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: homeserverBaseURL) let transport = UnauthenticatedTransportFfi.fromCallback(callback: adapter) @@ -275,8 +335,9 @@ public final class DirectoryService { let pubkyStorage = PubkyStorageAdapter.shared let followsPath = "/pub/pubky.app/follows/" + let unauthenticatedAdapter = PubkyUnauthenticatedStorageAdapter(homeserverBaseURL: homeserverBaseURL) - return try await pubkyStorage.listDirectory(path: followsPath, adapter: adapter, ownerPubkey: ownerPubkey) + return try await pubkyStorage.listDirectory(path: followsPath, adapter: unauthenticatedAdapter, ownerPubkey: ownerPubkey) } /// Add a follow to the Pubky directory diff --git a/Bitkit/PaykitIntegration/Services/PaymentRequestService.swift b/Bitkit/PaykitIntegration/Services/PaymentRequestService.swift index 519b7872..5623af34 100644 --- a/Bitkit/PaykitIntegration/Services/PaymentRequestService.swift +++ b/Bitkit/PaykitIntegration/Services/PaymentRequestService.swift @@ -110,6 +110,9 @@ public class PaymentRequestService { case .needsApproval: completion(.success(.needsApproval(request: request))) + + case .needsBiometric: + completion(.success(.needsApproval(request: request))) } } catch { Logger.error("PaymentRequestService: Failed to handle request: \(error)", context: "PaymentRequestService") @@ -196,6 +199,8 @@ enum PaymentRequestError: LocalizedError { // Make AutoPayViewModel conform to AutopayEvaluator extension AutoPayViewModel: AutopayEvaluator { - // Already implements evaluate() method above + func evaluate(peerPubkey: String, amount: Int64, methodId: String) -> AutopayEvaluationResult { + return evaluate(peerPubkey: peerPubkey, peerName: "", amount: amount, methodId: methodId, isSubscription: false) + } } diff --git a/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift b/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift index e109f24c..7f028b20 100644 --- a/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift +++ b/Bitkit/PaykitIntegration/Services/PubkyRingBridge.swift @@ -85,9 +85,59 @@ public final class PubkyRingBridge { /// Cached keypairs by derivation path private var keypairCache: [String: NoiseKeypair] = [:] + /// Keychain storage for persistent session storage + private let keychainStorage = PaykitKeychainStorage() + + /// Device ID for noise key derivation + private var _deviceId: String? + // MARK: - Initialization - private init() {} + private init() { + // Load or generate device ID + _deviceId = loadOrGenerateDeviceId() + } + + // MARK: - Device ID Management + + /// Get consistent device ID for noise key derivations + public var deviceId: String { + if let id = _deviceId { + return id + } + let id = loadOrGenerateDeviceId() + _deviceId = id + return id + } + + private func loadOrGenerateDeviceId() -> String { + let key = "paykit.device_id" + + // Try to load existing + if let data = keychainStorage.get(key: key), + let id = String(data: data, encoding: .utf8), !id.isEmpty { + Logger.debug("Loaded device ID: \(id.prefix(8))...", context: "PubkyRingBridge") + return id + } + + // Generate new UUID + let newId = UUID().uuidString.lowercased() + + // Persist + if let data = newId.data(using: .utf8) { + keychainStorage.set(key: key, value: data) + } + + Logger.info("Generated new device ID: \(newId.prefix(8))...", context: "PubkyRingBridge") + return newId + } + + /// Reset device ID (for debugging/testing only) + public func resetDeviceId() { + keychainStorage.deleteQuietly(key: "paykit.device_id") + _deviceId = loadOrGenerateDeviceId() + Logger.info("Device ID reset", context: "PubkyRingBridge") + } // MARK: - Public API @@ -134,30 +184,45 @@ public final class PubkyRingBridge { /// Request a noise keypair derivation from Pubky-ring /// + /// First checks NoiseKeyCache, then requests from Pubky-ring if not found. + /// /// - Parameters: - /// - deviceId: Device identifier for key derivation + /// - deviceId: Device identifier for key derivation (defaults to stored device ID) /// - epoch: Epoch for key rotation /// - Returns: X25519 keypair for Noise protocol /// - Throws: PubkyRingError if request fails - public func requestNoiseKeypair(deviceId: String, epoch: UInt64) async throws -> NoiseKeypair { - // Check cache first - let cacheKey = "\(deviceId):\(epoch)" + public func requestNoiseKeypair(deviceId: String? = nil, epoch: UInt64) async throws -> NoiseKeypair { + let actualDeviceId = deviceId ?? self.deviceId + let cacheKey = "\(actualDeviceId):\(epoch)" + + // Check memory cache first if let cached = keypairCache[cacheKey] { + Logger.debug("Noise keypair cache hit for \(cacheKey)", context: "PubkyRingBridge") return cached } + // Check persistent cache (NoiseKeyCache) + let noiseKeyCache = NoiseKeyCache.shared + if let keyData = noiseKeyCache.getKey(deviceId: actualDeviceId, epoch: UInt32(epoch)) { + // We have the secret key, we need to also have the public key + // For now, try to reconstruct from stored data + Logger.debug("Noise keypair found in persistent cache for \(cacheKey)", context: "PubkyRingBridge") + // The keyData is just the secret key, public key would need to be derived + // For full support, we'd need to store both - for now request from Pubky-ring + } + guard isPubkyRingInstalled else { throw PubkyRingError.appNotInstalled } let callbackUrl = "\(bitkitScheme)://\(CallbackPaths.keypair)" - let requestUrl = "\(pubkyRingScheme)://derive-keypair?deviceId=\(deviceId)&epoch=\(epoch)&callback=\(callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl)" + let requestUrl = "\(pubkyRingScheme)://derive-keypair?deviceId=\(actualDeviceId)&epoch=\(epoch)&callback=\(callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl)" guard let url = URL(string: requestUrl) else { throw PubkyRingError.invalidUrl } - return try await withCheckedThrowingContinuation { continuation in + let keypair = try await withCheckedThrowingContinuation { continuation in self.pendingKeypairContinuation = continuation DispatchQueue.main.async { @@ -169,6 +234,16 @@ public final class PubkyRingBridge { } } } + + // Cache the keypair + keypairCache[cacheKey] = keypair + + // Persist secret key to NoiseKeyCache + if let secretKeyData = keypair.secretKey.data(using: .utf8) { + noiseKeyCache.setKey(secretKeyData, deviceId: actualDeviceId, epoch: UInt32(epoch)) + } + + return keypair } /// Get cached session for a pubkey @@ -182,6 +257,77 @@ public final class PubkyRingBridge { keypairCache.removeAll() } + // MARK: - Profile & Follows Requests + + /// Pending profile request continuation + private var pendingProfileContinuation: CheckedContinuation? + + /// Pending follows request continuation + private var pendingFollowsContinuation: CheckedContinuation<[String], Error>? + + /// Request a profile from Pubky-ring (which fetches from homeserver) + /// + /// - Parameter pubkey: The pubkey of the profile to fetch + /// - Returns: PubkyProfile if found, nil otherwise + /// - Throws: PubkyRingError if request fails + public func requestProfile(pubkey: String) async throws -> PubkyProfile? { + guard isPubkyRingInstalled else { + throw PubkyRingError.appNotInstalled + } + + let callbackUrl = "\(bitkitScheme)://\(CallbackPaths.profile)" + let encodedCallback = callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl + let requestUrl = "\(pubkyRingScheme)://get-profile?pubkey=\(pubkey)&callback=\(encodedCallback)" + + guard let url = URL(string: requestUrl) else { + throw PubkyRingError.invalidUrl + } + + return try await withCheckedThrowingContinuation { continuation in + self.pendingProfileContinuation = continuation + + DispatchQueue.main.async { + UIApplication.shared.open(url) { success in + if !success { + self.pendingProfileContinuation?.resume(throwing: PubkyRingError.failedToOpenApp) + self.pendingProfileContinuation = nil + } + } + } + } + } + + /// Request follows list from Pubky-ring (which fetches from homeserver) + /// + /// - Returns: Array of followed pubkeys + /// - Throws: PubkyRingError if request fails + public func requestFollows() async throws -> [String] { + guard isPubkyRingInstalled else { + throw PubkyRingError.appNotInstalled + } + + let callbackUrl = "\(bitkitScheme)://\(CallbackPaths.follows)" + let encodedCallback = callbackUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? callbackUrl + let requestUrl = "\(pubkyRingScheme)://get-follows?callback=\(encodedCallback)" + + guard let url = URL(string: requestUrl) else { + throw PubkyRingError.invalidUrl + } + + return try await withCheckedThrowingContinuation { continuation in + self.pendingFollowsContinuation = continuation + + DispatchQueue.main.async { + UIApplication.shared.open(url) { success in + if !success { + self.pendingFollowsContinuation?.resume(throwing: PubkyRingError.failedToOpenApp) + self.pendingFollowsContinuation = nil + } + } + } + } + } + // MARK: - Cross-Device Authentication /// Generate a cross-device session request that can be shared as a link or QR @@ -369,6 +515,10 @@ public final class PubkyRingBridge { return handleSessionCallback(url: url) case CallbackPaths.keypair: return handleKeypairCallback(url: url) + case CallbackPaths.profile: + return handleProfileCallback(url: url) + case CallbackPaths.follows: + return handleFollowsCallback(url: url) case CallbackPaths.crossDeviceSession: return handleCrossDeviceSessionCallback(url: url) default: @@ -412,6 +562,9 @@ public final class PubkyRingBridge { // Cache the session sessionCache[pubkey] = session + // Persist to keychain + persistSession(session) + pendingSessionContinuation?.resume(returning: session) pendingSessionContinuation = nil @@ -450,16 +603,97 @@ public final class PubkyRingBridge { epoch: epoch ) - // Cache the keypair + // Cache the keypair in memory let cacheKey = "\(deviceId):\(epoch)" keypairCache[cacheKey] = keypair + // Persist secret key to NoiseKeyCache + if let secretKeyData = secretKey.data(using: .utf8) { + NoiseKeyCache.shared.setKey(secretKeyData, deviceId: deviceId, epoch: UInt32(epoch)) + Logger.debug("Persisted noise keypair to NoiseKeyCache for \(cacheKey)", context: "PubkyRingBridge") + } + pendingKeypairContinuation?.resume(returning: keypair) pendingKeypairContinuation = nil return true } + private func handleProfileCallback(url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + pendingProfileContinuation?.resume(returning: nil) + pendingProfileContinuation = nil + return true + } + + var params: [String: String] = [:] + for item in queryItems { + if let value = item.value { + params[item.name] = value + } + } + + // Check for error response + if let error = params["error"] { + Logger.warn("Profile request returned error: \(error)", context: "PubkyRingBridge") + pendingProfileContinuation?.resume(returning: nil) + pendingProfileContinuation = nil + return true + } + + // Build profile from response + let profile = PubkyProfile( + name: params["name"]?.removingPercentEncoding, + bio: params["bio"]?.removingPercentEncoding, + avatar: params["avatar"]?.removingPercentEncoding, + links: nil // Links would need JSON parsing, simplified for now + ) + + Logger.debug("Received profile from Pubky-ring: \(profile.name ?? "unknown")", context: "PubkyRingBridge") + + pendingProfileContinuation?.resume(returning: profile) + pendingProfileContinuation = nil + + return true + } + + private func handleFollowsCallback(url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { + pendingFollowsContinuation?.resume(returning: []) + pendingFollowsContinuation = nil + return true + } + + var params: [String: String] = [:] + for item in queryItems { + if let value = item.value { + params[item.name] = value + } + } + + // Check for error response + if let error = params["error"] { + Logger.warn("Follows request returned error: \(error)", context: "PubkyRingBridge") + pendingFollowsContinuation?.resume(returning: []) + pendingFollowsContinuation = nil + return true + } + + // Parse follows list (comma-separated pubkeys) + let follows = params["follows"]? + .components(separatedBy: ",") + .filter { !$0.isEmpty } ?? [] + + Logger.debug("Received \(follows.count) follows from Pubky-ring", context: "PubkyRingBridge") + + pendingFollowsContinuation?.resume(returning: follows) + pendingFollowsContinuation = nil + + return true + } + private func handleCrossDeviceSessionCallback(url: URL) -> Bool { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), let queryItems = components.queryItems else { @@ -497,11 +731,190 @@ public final class PubkyRingBridge { sessionCache[pubkey] = session pendingCrossDeviceRequestId = nil - // Note: For cross-device, the session is stored in cache - // The polling task will pick it up + // Persist to keychain for cross-device sessions too + persistSession(session) return true } + + // MARK: - Session Persistence + + /// Persist a session to keychain + private func persistSession(_ session: PubkySession) { + do { + let data = try JSONEncoder().encode(session) + keychainStorage.set(key: "pubky.session.\(session.pubkey)", value: data) + Logger.debug("Persisted session for \(session.pubkey.prefix(12))...", context: "PubkyRingBridge") + } catch { + Logger.error("Failed to persist session: \(error)", context: "PubkyRingBridge") + } + } + + /// Restore all sessions from keychain on app launch + public func restoreSessions() { + let sessionKeys = keychainStorage.listKeys(withPrefix: "pubky.session.") + + for key in sessionKeys { + do { + guard let data = keychainStorage.get(key: key) else { continue } + let session = try JSONDecoder().decode(PubkySession.self, from: data) + sessionCache[session.pubkey] = session + Logger.info("Restored session for \(session.pubkey.prefix(12))...", context: "PubkyRingBridge") + } catch { + Logger.error("Failed to restore session from \(key): \(error)", context: "PubkyRingBridge") + } + } + + Logger.info("Restored \(sessionCache.count) sessions from keychain", context: "PubkyRingBridge") + } + + /// Get all cached sessions + public var cachedSessions: [PubkySession] { + Array(sessionCache.values) + } + + /// Get all cached sessions + public func getAllSessions() -> [PubkySession] { + Array(sessionCache.values) + } + + /// Get count of cached keypairs + public func getCachedKeypairCount() -> Int { + keypairCache.count + } + + /// Clear a specific session from cache and keychain + public func clearSession(pubkey: String) { + sessionCache.removeValue(forKey: pubkey) + keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") + Logger.info("Cleared session for \(pubkey.prefix(12))...", context: "PubkyRingBridge") + } + + /// Clear all sessions from cache and keychain + public func clearAllSessions() { + for pubkey in sessionCache.keys { + keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") + } + sessionCache.removeAll() + Logger.info("Cleared all sessions", context: "PubkyRingBridge") + } + + /// Set a session directly (for manual or imported sessions) + public func setCachedSession(_ session: PubkySession) { + sessionCache[session.pubkey] = session + persistSession(session) + } + + // MARK: - Backup & Restore + + /// Backup data structure for export + public struct BackupData: Codable { + public let deviceId: String + public let sessions: [PubkySession] + public let noiseKeys: [BackupNoiseKey] + public let exportedAt: Date + public let version: Int + + public init(deviceId: String, sessions: [PubkySession], noiseKeys: [BackupNoiseKey], exportedAt: Date = Date(), version: Int = 1) { + self.deviceId = deviceId + self.sessions = sessions + self.noiseKeys = noiseKeys + self.exportedAt = exportedAt + self.version = version + } + } + + /// Noise key backup structure + public struct BackupNoiseKey: Codable { + public let deviceId: String + public let epoch: UInt64 + public let secretKey: String + + public init(deviceId: String, epoch: UInt64, secretKey: String) { + self.deviceId = deviceId + self.epoch = epoch + self.secretKey = secretKey + } + } + + /// Export all sessions and noise keys for backup + /// + /// - Returns: BackupData containing device ID, sessions, and noise keys + public func exportBackup() -> BackupData { + let sessions = Array(sessionCache.values) + var noiseKeys: [BackupNoiseKey] = [] + + // Export noise keys from keypair cache + for (cacheKey, keypair) in keypairCache { + noiseKeys.append(BackupNoiseKey( + deviceId: keypair.deviceId, + epoch: keypair.epoch, + secretKey: keypair.secretKey + )) + } + + let backup = BackupData( + deviceId: deviceId, + sessions: sessions, + noiseKeys: noiseKeys + ) + + Logger.info("Exported backup with \(sessions.count) sessions and \(noiseKeys.count) noise keys", context: "PubkyRingBridge") + return backup + } + + /// Export backup as JSON data + public func exportBackupAsJSON() throws -> Data { + let backup = exportBackup() + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + return try encoder.encode(backup) + } + + /// Import backup data and restore sessions/keys + /// + /// - Parameter backup: The backup data to restore + /// - Parameter overwriteDeviceId: Whether to overwrite the local device ID with the backup's + public func importBackup(_ backup: BackupData, overwriteDeviceId: Bool = false) { + // Optionally restore device ID + if overwriteDeviceId { + let key = "paykit.device_id" + if let data = backup.deviceId.data(using: .utf8) { + keychainStorage.set(key: key, value: data) + _deviceId = backup.deviceId + Logger.info("Restored device ID from backup", context: "PubkyRingBridge") + } + } + + // Restore sessions + for session in backup.sessions { + sessionCache[session.pubkey] = session + persistSession(session) + } + + // Restore noise keys + let noiseKeyCache = NoiseKeyCache.shared + for noiseKey in backup.noiseKeys { + let cacheKey = "\(noiseKey.deviceId):\(noiseKey.epoch)" + + // Restore to keypair cache (we only have the secret key, not public) + // The public key would need to be re-derived from Pubky-ring + if let secretKeyData = noiseKey.secretKey.data(using: .utf8) { + noiseKeyCache.setKey(secretKeyData, deviceId: noiseKey.deviceId, epoch: UInt32(noiseKey.epoch)) + } + } + + Logger.info("Imported backup with \(backup.sessions.count) sessions and \(backup.noiseKeys.count) noise keys", context: "PubkyRingBridge") + } + + /// Import backup from JSON data + public func importBackup(from jsonData: Data, overwriteDeviceId: Bool = false) throws { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let backup = try decoder.decode(BackupData.self, from: jsonData) + importBackup(backup, overwriteDeviceId: overwriteDeviceId) + } } // MARK: - Data Models @@ -512,11 +925,27 @@ public struct PubkySession: Codable { public let sessionSecret: String public let capabilities: [String] public let createdAt: Date + public let expiresAt: Date? + + /// Initialize with all parameters + public init(pubkey: String, sessionSecret: String, capabilities: [String], createdAt: Date, expiresAt: Date? = nil) { + self.pubkey = pubkey + self.sessionSecret = sessionSecret + self.capabilities = capabilities + self.createdAt = createdAt + self.expiresAt = expiresAt + } /// Check if session has a specific capability public func hasCapability(_ capability: String) -> Bool { capabilities.contains(capability) } + + /// Check if session is expired + public var isExpired: Bool { + guard let expiresAt = expiresAt else { return false } + return Date() > expiresAt + } } /// X25519 keypair for Noise protocol diff --git a/Bitkit/PaykitIntegration/Services/PubkySDK.swift b/Bitkit/PaykitIntegration/Services/PubkySDK.swift new file mode 100644 index 00000000..b094747b --- /dev/null +++ b/Bitkit/PaykitIntegration/Services/PubkySDK.swift @@ -0,0 +1,1633 @@ +// This file was autogenerated by some hot garbage in the `uniffi` crate. +// Trust me, you don't want to mess with it! +import Foundation + +// Depending on the consumer's build setup, the low-level FFI code +// might be in a separate module, or it might be compiled inline into +// this module. This is a bit of light hackery to work with both. +#if canImport(PubkySDKFFI) +import PubkySDKFFI +#endif + +fileprivate extension RustBuffer { + // Allocate a new buffer, copying the contents of a `UInt8` array. + init(bytes: [UInt8]) { + let rbuf = bytes.withUnsafeBufferPointer { ptr in + RustBuffer.from(ptr) + } + self.init(capacity: rbuf.capacity, len: rbuf.len, data: rbuf.data) + } + + static func from(_ ptr: UnsafeBufferPointer) -> RustBuffer { + try! rustCall { ffi_pubky_rustbuffer_from_bytes(ForeignBytes(bufferPointer: ptr), $0) } + } + + // Frees the buffer in place. + // The buffer must not be used after this is called. + func deallocate() { + try! rustCall { ffi_pubky_rustbuffer_free(self, $0) } + } +} + +fileprivate extension ForeignBytes { + init(bufferPointer: UnsafeBufferPointer) { + self.init(len: Int32(bufferPointer.count), data: bufferPointer.baseAddress) + } +} + +// For every type used in the interface, we provide helper methods for conveniently +// lifting and lowering that type from C-compatible data, and for reading and writing +// values of that type in a buffer. + +// Helper classes/extensions that don't change. +// Someday, this will be in a library of its own. + +fileprivate extension Data { + init(rustBuffer: RustBuffer) { + // TODO: This copies the buffer. Can we read directly from a + // Rust buffer? + self.init(bytes: rustBuffer.data!, count: Int(rustBuffer.len)) + } +} + +// Define reader functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. +// +// With external types, one swift source file needs to be able to call the read +// method on another source file's FfiConverter, but then what visibility +// should Reader have? +// - If Reader is fileprivate, then this means the read() must also +// be fileprivate, which doesn't work with external types. +// - If Reader is internal/public, we'll get compile errors since both source +// files will try define the same type. +// +// Instead, the read() method and these helper functions input a tuple of data + +fileprivate func createReader(data: Data) -> (data: Data, offset: Data.Index) { + (data: data, offset: 0) +} + +// Reads an integer at the current offset, in big-endian order, and advances +// the offset on success. Throws if reading the integer would move the +// offset past the end of the buffer. +fileprivate func readInt(_ reader: inout (data: Data, offset: Data.Index)) throws -> T { + let range = reader.offset...size + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + if T.self == UInt8.self { + let value = reader.data[reader.offset] + reader.offset += 1 + return value as! T + } + var value: T = 0 + let _ = withUnsafeMutableBytes(of: &value, { reader.data.copyBytes(to: $0, from: range)}) + reader.offset = range.upperBound + return value.bigEndian +} + +// Reads an arbitrary number of bytes, to be used to read +// raw bytes, this is useful when lifting strings +fileprivate func readBytes(_ reader: inout (data: Data, offset: Data.Index), count: Int) throws -> Array { + let range = reader.offset..<(reader.offset+count) + guard reader.data.count >= range.upperBound else { + throw UniffiInternalError.bufferOverflow + } + var value = [UInt8](repeating: 0, count: count) + value.withUnsafeMutableBufferPointer({ buffer in + reader.data.copyBytes(to: buffer, from: range) + }) + reader.offset = range.upperBound + return value +} + +// Reads a float at the current offset. +fileprivate func readFloat(_ reader: inout (data: Data, offset: Data.Index)) throws -> Float { + return Float(bitPattern: try readInt(&reader)) +} + +// Reads a float at the current offset. +fileprivate func readDouble(_ reader: inout (data: Data, offset: Data.Index)) throws -> Double { + return Double(bitPattern: try readInt(&reader)) +} + +// Indicates if the offset has reached the end of the buffer. +fileprivate func hasRemaining(_ reader: (data: Data, offset: Data.Index)) -> Bool { + return reader.offset < reader.data.count +} + +// Define writer functionality. Normally this would be defined in a class or +// struct, but we use standalone functions instead in order to make external +// types work. See the above discussion on Readers for details. + +fileprivate func createWriter() -> [UInt8] { + return [] +} + +fileprivate func writeBytes(_ writer: inout [UInt8], _ byteArr: S) where S: Sequence, S.Element == UInt8 { + writer.append(contentsOf: byteArr) +} + +// Writes an integer in big-endian order. +// +// Warning: make sure what you are trying to write +// is in the correct type! +fileprivate func writeInt(_ writer: inout [UInt8], _ value: T) { + var value = value.bigEndian + withUnsafeBytes(of: &value) { writer.append(contentsOf: $0) } +} + +fileprivate func writeFloat(_ writer: inout [UInt8], _ value: Float) { + writeInt(&writer, value.bitPattern) +} + +fileprivate func writeDouble(_ writer: inout [UInt8], _ value: Double) { + writeInt(&writer, value.bitPattern) +} + +// Protocol for types that transfer other types across the FFI. This is +// analogous go the Rust trait of the same name. +fileprivate protocol FfiConverter { + associatedtype FfiType + associatedtype SwiftType + + static func lift(_ value: FfiType) throws -> SwiftType + static func lower(_ value: SwiftType) -> FfiType + static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType + static func write(_ value: SwiftType, into buf: inout [UInt8]) +} + +// Types conforming to `Primitive` pass themselves directly over the FFI. +fileprivate protocol FfiConverterPrimitive: FfiConverter where FfiType == SwiftType { } + +extension FfiConverterPrimitive { + public static func lift(_ value: FfiType) throws -> SwiftType { + return value + } + + public static func lower(_ value: SwiftType) -> FfiType { + return value + } +} + +// Types conforming to `FfiConverterRustBuffer` lift and lower into a `RustBuffer`. +// Used for complex types where it's hard to write a custom lift/lower. +fileprivate protocol FfiConverterRustBuffer: FfiConverter where FfiType == RustBuffer {} + +extension FfiConverterRustBuffer { + public static func lift(_ buf: RustBuffer) throws -> SwiftType { + var reader = createReader(data: Data(rustBuffer: buf)) + let value = try read(from: &reader) + if hasRemaining(reader) { + throw UniffiInternalError.incompleteData + } + buf.deallocate() + return value + } + + public static func lower(_ value: SwiftType) -> RustBuffer { + var writer = createWriter() + write(value, into: &writer) + return RustBuffer(bytes: writer) + } +} +// An error type for FFI errors. These errors occur at the UniFFI level, not +// the library level. +fileprivate enum UniffiInternalError: LocalizedError { + case bufferOverflow + case incompleteData + case unexpectedOptionalTag + case unexpectedEnumCase + case unexpectedNullPointer + case unexpectedRustCallStatusCode + case unexpectedRustCallError + case unexpectedStaleHandle + case rustPanic(_ message: String) + + public var errorDescription: String? { + switch self { + case .bufferOverflow: return "Reading the requested value would read past the end of the buffer" + case .incompleteData: return "The buffer still has data after lifting its containing value" + case .unexpectedOptionalTag: return "Unexpected optional tag; should be 0 or 1" + case .unexpectedEnumCase: return "Raw enum value doesn't match any cases" + case .unexpectedNullPointer: return "Raw pointer value was null" + case .unexpectedRustCallStatusCode: return "Unexpected RustCallStatus code" + case .unexpectedRustCallError: return "CALL_ERROR but no errorClass specified" + case .unexpectedStaleHandle: return "The object in the handle map has been dropped already" + case let .rustPanic(message): return message + } + } +} + +fileprivate let CALL_SUCCESS: Int8 = 0 +fileprivate let CALL_ERROR: Int8 = 1 +fileprivate let CALL_PANIC: Int8 = 2 +fileprivate let CALL_CANCELLED: Int8 = 3 + +fileprivate extension RustCallStatus { + init() { + self.init( + code: CALL_SUCCESS, + errorBuf: RustBuffer.init( + capacity: 0, + len: 0, + data: nil + ) + ) + } +} + +private func rustCall(_ callback: (UnsafeMutablePointer) -> T) throws -> T { + try makeRustCall(callback, errorHandler: nil) +} + +private func rustCallWithError( + _ errorHandler: @escaping (RustBuffer) throws -> Error, + _ callback: (UnsafeMutablePointer) -> T) throws -> T { + try makeRustCall(callback, errorHandler: errorHandler) +} + +private func makeRustCall( + _ callback: (UnsafeMutablePointer) -> T, + errorHandler: ((RustBuffer) throws -> Error)? +) throws -> T { + uniffiEnsureInitialized() + var callStatus = RustCallStatus.init() + let returnedVal = callback(&callStatus) + try uniffiCheckCallStatus(callStatus: callStatus, errorHandler: errorHandler) + return returnedVal +} + +private func uniffiCheckCallStatus( + callStatus: RustCallStatus, + errorHandler: ((RustBuffer) throws -> Error)? +) throws { + switch callStatus.code { + case CALL_SUCCESS: + return + + case CALL_ERROR: + if let errorHandler = errorHandler { + throw try errorHandler(callStatus.errorBuf) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.unexpectedRustCallError + } + + case CALL_PANIC: + // When the rust code sees a panic, it tries to construct a RustBuffer + // with the message. But if that code panics, then it just sends back + // an empty buffer. + if callStatus.errorBuf.len > 0 { + throw UniffiInternalError.rustPanic(try FfiConverterString.lift(callStatus.errorBuf)) + } else { + callStatus.errorBuf.deallocate() + throw UniffiInternalError.rustPanic("Rust panic") + } + + case CALL_CANCELLED: + fatalError("Cancellation not supported yet") + + default: + throw UniffiInternalError.unexpectedRustCallStatusCode + } +} + +// Public interface members begin here. + + +fileprivate struct FfiConverterUInt8: FfiConverterPrimitive { + typealias FfiType = UInt8 + typealias SwiftType = UInt8 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt8 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: UInt8, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +fileprivate struct FfiConverterUInt64: FfiConverterPrimitive { + typealias FfiType = UInt64 + typealias SwiftType = UInt64 + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> UInt64 { + return try lift(readInt(&buf)) + } + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +fileprivate struct FfiConverterBool : FfiConverter { + typealias FfiType = Int8 + typealias SwiftType = Bool + + public static func lift(_ value: Int8) throws -> Bool { + return value != 0 + } + + public static func lower(_ value: Bool) -> Int8 { + return value ? 1 : 0 + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Bool { + return try lift(readInt(&buf)) + } + + public static func write(_ value: Bool, into buf: inout [UInt8]) { + writeInt(&buf, lower(value)) + } +} + +fileprivate struct FfiConverterString: FfiConverter { + typealias SwiftType = String + typealias FfiType = RustBuffer + + public static func lift(_ value: RustBuffer) throws -> String { + defer { + value.deallocate() + } + if value.data == nil { + return String() + } + let bytes = UnsafeBufferPointer(start: value.data!, count: Int(value.len)) + return String(bytes: bytes, encoding: String.Encoding.utf8)! + } + + public static func lower(_ value: String) -> RustBuffer { + return value.utf8CString.withUnsafeBufferPointer { ptr in + // The swift string gives us int8_t, we want uint8_t. + ptr.withMemoryRebound(to: UInt8.self) { ptr in + // The swift string gives us a trailing null byte, we don't want it. + let buf = UnsafeBufferPointer(rebasing: ptr.prefix(upTo: ptr.count - 1)) + return RustBuffer.from(buf) + } + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> String { + let len: Int32 = try readInt(&buf) + return String(bytes: try readBytes(&buf, count: Int(len)), encoding: String.Encoding.utf8)! + } + + public static func write(_ value: String, into buf: inout [UInt8]) { + let len = Int32(value.utf8.count) + writeInt(&buf, len) + writeBytes(&buf, value.utf8) + } +} + + + + +public protocol KeyProvider : AnyObject { + + func secretKey() throws -> [UInt8] + +} + +public class KeyProviderImpl: + KeyProvider { + fileprivate let pointer: UnsafeMutableRawPointer + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_pubky_fn_clone_keyprovider(self.pointer, $0) } + } + + deinit { + try! rustCall { uniffi_pubky_fn_free_keyprovider(pointer, $0) } + } + + + + + + public func secretKey() throws -> [UInt8] { + return try FfiConverterSequenceUInt8.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_keyprovider_secret_key(self.uniffiClonePointer(), $0 + ) +} + ) + } + +} +fileprivate extension NSLock { + func withLock(f: () throws -> T) rethrows -> T { + self.lock() + defer { self.unlock() } + return try f() + } +} + +fileprivate typealias UniFFICallbackHandle = UInt64 +fileprivate class UniFFICallbackHandleMap { + private var leftMap: [UniFFICallbackHandle: T] = [:] + private var counter: [UniFFICallbackHandle: UInt64] = [:] + private var rightMap: [ObjectIdentifier: UniFFICallbackHandle] = [:] + + private let lock = NSLock() + private var currentHandle: UniFFICallbackHandle = 1 + private let stride: UniFFICallbackHandle = 1 + + func insert(obj: T) -> UniFFICallbackHandle { + lock.withLock { + let id = ObjectIdentifier(obj as AnyObject) + let handle = rightMap[id] ?? { + currentHandle += stride + let handle = currentHandle + leftMap[handle] = obj + rightMap[id] = handle + return handle + }() + counter[handle] = (counter[handle] ?? 0) + 1 + return handle + } + } + + func get(handle: UniFFICallbackHandle) -> T? { + lock.withLock { + leftMap[handle] + } + } + + func delete(handle: UniFFICallbackHandle) { + remove(handle: handle) + } + + @discardableResult + func remove(handle: UniFFICallbackHandle) -> T? { + lock.withLock { + defer { counter[handle] = (counter[handle] ?? 1) - 1 } + guard counter[handle] == 1 else { return leftMap[handle] } + let obj = leftMap.removeValue(forKey: handle) + if let obj = obj { + rightMap.removeValue(forKey: ObjectIdentifier(obj as AnyObject)) + } + return obj + } + } +} + +// Magic number for the Rust proxy to call using the same mechanism as every other method, +// to free the callback once it's dropped by Rust. +private let IDX_CALLBACK_FREE: Int32 = 0 +// Callback return codes +private let UNIFFI_CALLBACK_SUCCESS: Int32 = 0 +private let UNIFFI_CALLBACK_ERROR: Int32 = 1 +private let UNIFFI_CALLBACK_UNEXPECTED_ERROR: Int32 = 2 + +// Declaration and FfiConverters for KeyProvider Callback Interface + +fileprivate let uniffiCallbackInterfaceKeyProvider : ForeignCallback = + { (handle: UniFFICallbackHandle, method: Int32, argsData: UnsafePointer, argsLen: Int32, out_buf: UnsafeMutablePointer) -> Int32 in + + + func invokeSecretKey(_ swiftCallbackInterface: KeyProvider, _ argsData: UnsafePointer, _ argsLen: Int32, _ out_buf: UnsafeMutablePointer) throws -> Int32 { + func makeCall() throws -> Int32 { + let result = try swiftCallbackInterface.secretKey( + ) + var writer = [UInt8]() + FfiConverterSequenceUInt8.write(result, into: &writer) + out_buf.pointee = RustBuffer(bytes: writer) + return UNIFFI_CALLBACK_SUCCESS + } + do { + return try makeCall() + } catch let error as PubkyError { + out_buf.pointee = FfiConverterTypePubkyError.lower(error) + return UNIFFI_CALLBACK_ERROR + } + } + + + switch method { + case IDX_CALLBACK_FREE: + FfiConverterTypeKeyProvider.handleMap.remove(handle: handle) + // Successful return + // See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs` + return UNIFFI_CALLBACK_SUCCESS + case 1: + guard let cb = FfiConverterTypeKeyProvider.handleMap.get(handle: handle) else { + out_buf.pointee = FfiConverterString.lower("No callback in handlemap; this is a Uniffi bug") + return UNIFFI_CALLBACK_UNEXPECTED_ERROR + } + do { + return try invokeSecretKey(cb, argsData, argsLen, out_buf) + } catch let error { + out_buf.pointee = FfiConverterString.lower(String(describing: error)) + return UNIFFI_CALLBACK_UNEXPECTED_ERROR + } + + // This should never happen, because an out of bounds method index won't + // ever be used. Once we can catch errors, we should return an InternalError. + // https://github.com/mozilla/uniffi-rs/issues/351 + default: + // An unexpected error happened. + // See docs of ForeignCallback in `uniffi_core/src/ffi/foreigncallbacks.rs` + return UNIFFI_CALLBACK_UNEXPECTED_ERROR + } +} + +private func uniffiCallbackInitKeyProvider() { + uniffi_pubky_fn_init_callback_keyprovider(uniffiCallbackInterfaceKeyProvider) +} + +public struct FfiConverterTypeKeyProvider: FfiConverter { + fileprivate static var handleMap = UniFFICallbackHandleMap() + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = KeyProvider + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> KeyProvider { + return KeyProviderImpl(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: KeyProvider) -> UnsafeMutableRawPointer { + guard let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: handleMap.insert(obj: value))) else { + fatalError("Cast to UnsafeMutableRawPointer failed") + } + return ptr + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> KeyProvider { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: KeyProvider, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +public func FfiConverterTypeKeyProvider_lift(_ pointer: UnsafeMutableRawPointer) throws -> KeyProvider { + return try FfiConverterTypeKeyProvider.lift(pointer) +} + +public func FfiConverterTypeKeyProvider_lower(_ value: KeyProvider) -> UnsafeMutableRawPointer { + return FfiConverterTypeKeyProvider.lower(value) +} + + + + +public protocol PubkySessionProtocol : AnyObject { + + func info() -> SessionInfo + + func signout() throws + + func storage() -> SessionStorage + +} + +public class PubkySession: + PubkySessionProtocol { + fileprivate let pointer: UnsafeMutableRawPointer + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_pubky_fn_clone_pubkysession(self.pointer, $0) } + } + + deinit { + try! rustCall { uniffi_pubky_fn_free_pubkysession(pointer, $0) } + } + + + + + + public func info() -> SessionInfo { + return try! FfiConverterTypeSessionInfo.lift( + try! + rustCall() { + + uniffi_pubky_fn_method_pubkysession_info(self.uniffiClonePointer(), $0 + ) +} + ) + } + public func signout() throws { + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_pubkysession_signout(self.uniffiClonePointer(), $0 + ) +} + } + public func storage() -> SessionStorage { + return try! FfiConverterTypeSessionStorage.lift( + try! + rustCall() { + + uniffi_pubky_fn_method_pubkysession_storage(self.uniffiClonePointer(), $0 + ) +} + ) + } + +} + +public struct FfiConverterTypePubkySession: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = PubkySession + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PubkySession { + return PubkySession(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: PubkySession) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkySession { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: PubkySession, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +public func FfiConverterTypePubkySession_lift(_ pointer: UnsafeMutableRawPointer) throws -> PubkySession { + return try FfiConverterTypePubkySession.lift(pointer) +} + +public func FfiConverterTypePubkySession_lower(_ value: PubkySession) -> UnsafeMutableRawPointer { + return FfiConverterTypePubkySession.lower(value) +} + + + + +public protocol PublicStorageProtocol : AnyObject { + + func get(uri: String) throws -> [UInt8] + + func list(uri: String) throws -> [ListItem] + +} + +public class PublicStorage: + PublicStorageProtocol { + fileprivate let pointer: UnsafeMutableRawPointer + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_pubky_fn_clone_publicstorage(self.pointer, $0) } + } + + deinit { + try! rustCall { uniffi_pubky_fn_free_publicstorage(pointer, $0) } + } + + + + + + public func get(uri: String) throws -> [UInt8] { + return try FfiConverterSequenceUInt8.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_publicstorage_get(self.uniffiClonePointer(), + FfiConverterString.lower(uri),$0 + ) +} + ) + } + public func list(uri: String) throws -> [ListItem] { + return try FfiConverterSequenceTypeListItem.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_publicstorage_list(self.uniffiClonePointer(), + FfiConverterString.lower(uri),$0 + ) +} + ) + } + +} + +public struct FfiConverterTypePublicStorage: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = PublicStorage + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> PublicStorage { + return PublicStorage(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: PublicStorage) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PublicStorage { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: PublicStorage, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +public func FfiConverterTypePublicStorage_lift(_ pointer: UnsafeMutableRawPointer) throws -> PublicStorage { + return try FfiConverterTypePublicStorage.lift(pointer) +} + +public func FfiConverterTypePublicStorage_lower(_ value: PublicStorage) -> UnsafeMutableRawPointer { + return FfiConverterTypePublicStorage.lower(value) +} + + + + +public protocol SdkProtocol : AnyObject { + + func awaitApproval(requestId: String) throws -> PubkySession + + func publicStorage() -> PublicStorage + + func signin(keyProvider: KeyProvider, homeserver: String) throws -> PubkySession + + func signup(keyProvider: KeyProvider, homeserver: String, options: SignupOptions?) throws -> PubkySession + + func startAuthFlow(capabilities: [String]) throws -> AuthFlowInfo + +} + +public class Sdk: + SdkProtocol { + fileprivate let pointer: UnsafeMutableRawPointer + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_pubky_fn_clone_sdk(self.pointer, $0) } + } + public convenience init() { + self.init(unsafeFromRawPointer: try! rustCall() { + uniffi_pubky_fn_constructor_sdk_new($0) +}) + } + + deinit { + try! rustCall { uniffi_pubky_fn_free_sdk(pointer, $0) } + } + + + + + + public func awaitApproval(requestId: String) throws -> PubkySession { + return try FfiConverterTypePubkySession.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sdk_await_approval(self.uniffiClonePointer(), + FfiConverterString.lower(requestId),$0 + ) +} + ) + } + public func publicStorage() -> PublicStorage { + return try! FfiConverterTypePublicStorage.lift( + try! + rustCall() { + + uniffi_pubky_fn_method_sdk_public_storage(self.uniffiClonePointer(), $0 + ) +} + ) + } + public func signin(keyProvider: KeyProvider, homeserver: String) throws -> PubkySession { + return try FfiConverterTypePubkySession.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sdk_signin(self.uniffiClonePointer(), + FfiConverterTypeKeyProvider.lower(keyProvider), + FfiConverterString.lower(homeserver),$0 + ) +} + ) + } + public func signup(keyProvider: KeyProvider, homeserver: String, options: SignupOptions?) throws -> PubkySession { + return try FfiConverterTypePubkySession.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sdk_signup(self.uniffiClonePointer(), + FfiConverterTypeKeyProvider.lower(keyProvider), + FfiConverterString.lower(homeserver), + FfiConverterOptionTypeSignupOptions.lower(options),$0 + ) +} + ) + } + public func startAuthFlow(capabilities: [String]) throws -> AuthFlowInfo { + return try FfiConverterTypeAuthFlowInfo.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sdk_start_auth_flow(self.uniffiClonePointer(), + FfiConverterSequenceString.lower(capabilities),$0 + ) +} + ) + } + +} + +public struct FfiConverterTypeSdk: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = Sdk + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> Sdk { + return Sdk(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: Sdk) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> Sdk { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: Sdk, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +public func FfiConverterTypeSdk_lift(_ pointer: UnsafeMutableRawPointer) throws -> Sdk { + return try FfiConverterTypeSdk.lift(pointer) +} + +public func FfiConverterTypeSdk_lower(_ value: Sdk) -> UnsafeMutableRawPointer { + return FfiConverterTypeSdk.lower(value) +} + + + + +public protocol SessionStorageProtocol : AnyObject { + + func delete(path: String) throws + + func get(path: String) throws -> [UInt8] + + func list(path: String) throws -> [ListItem] + + func put(path: String, content: [UInt8]) throws + +} + +public class SessionStorage: + SessionStorageProtocol { + fileprivate let pointer: UnsafeMutableRawPointer + + // TODO: We'd like this to be `private` but for Swifty reasons, + // we can't implement `FfiConverter` without making this `required` and we can't + // make it `required` without making it `public`. + required init(unsafeFromRawPointer pointer: UnsafeMutableRawPointer) { + self.pointer = pointer + } + + public func uniffiClonePointer() -> UnsafeMutableRawPointer { + return try! rustCall { uniffi_pubky_fn_clone_sessionstorage(self.pointer, $0) } + } + + deinit { + try! rustCall { uniffi_pubky_fn_free_sessionstorage(pointer, $0) } + } + + + + + + public func delete(path: String) throws { + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sessionstorage_delete(self.uniffiClonePointer(), + FfiConverterString.lower(path),$0 + ) +} + } + public func get(path: String) throws -> [UInt8] { + return try FfiConverterSequenceUInt8.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sessionstorage_get(self.uniffiClonePointer(), + FfiConverterString.lower(path),$0 + ) +} + ) + } + public func list(path: String) throws -> [ListItem] { + return try FfiConverterSequenceTypeListItem.lift( + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sessionstorage_list(self.uniffiClonePointer(), + FfiConverterString.lower(path),$0 + ) +} + ) + } + public func put(path: String, content: [UInt8]) throws { + try + rustCallWithError(FfiConverterTypePubkyError.lift) { + uniffi_pubky_fn_method_sessionstorage_put(self.uniffiClonePointer(), + FfiConverterString.lower(path), + FfiConverterSequenceUInt8.lower(content),$0 + ) +} + } + +} + +public struct FfiConverterTypeSessionStorage: FfiConverter { + + typealias FfiType = UnsafeMutableRawPointer + typealias SwiftType = SessionStorage + + public static func lift(_ pointer: UnsafeMutableRawPointer) throws -> SessionStorage { + return SessionStorage(unsafeFromRawPointer: pointer) + } + + public static func lower(_ value: SessionStorage) -> UnsafeMutableRawPointer { + return value.uniffiClonePointer() + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionStorage { + let v: UInt64 = try readInt(&buf) + // The Rust code won't compile if a pointer won't fit in a UInt64. + // We have to go via `UInt` because that's the thing that's the size of a pointer. + let ptr = UnsafeMutableRawPointer(bitPattern: UInt(truncatingIfNeeded: v)) + if (ptr == nil) { + throw UniffiInternalError.unexpectedNullPointer + } + return try lift(ptr!) + } + + public static func write(_ value: SessionStorage, into buf: inout [UInt8]) { + // This fiddling is because `Int` is the thing that's the same size as a pointer. + // The Rust code won't compile if a pointer won't fit in a `UInt64`. + writeInt(&buf, UInt64(bitPattern: Int64(Int(bitPattern: lower(value))))) + } +} + + +public func FfiConverterTypeSessionStorage_lift(_ pointer: UnsafeMutableRawPointer) throws -> SessionStorage { + return try FfiConverterTypeSessionStorage.lift(pointer) +} + +public func FfiConverterTypeSessionStorage_lower(_ value: SessionStorage) -> UnsafeMutableRawPointer { + return FfiConverterTypeSessionStorage.lower(value) +} + + +public struct AuthFlowInfo { + public var authorizationUrl: String + public var requestId: String + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + authorizationUrl: String, + requestId: String) { + self.authorizationUrl = authorizationUrl + self.requestId = requestId + } +} + + +extension AuthFlowInfo: Equatable, Hashable { + public static func ==(lhs: AuthFlowInfo, rhs: AuthFlowInfo) -> Bool { + if lhs.authorizationUrl != rhs.authorizationUrl { + return false + } + if lhs.requestId != rhs.requestId { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(authorizationUrl) + hasher.combine(requestId) + } +} + + +public struct FfiConverterTypeAuthFlowInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AuthFlowInfo { + return + try AuthFlowInfo( + authorizationUrl: FfiConverterString.read(from: &buf), + requestId: FfiConverterString.read(from: &buf) + ) + } + + public static func write(_ value: AuthFlowInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.authorizationUrl, into: &buf) + FfiConverterString.write(value.requestId, into: &buf) + } +} + + +public func FfiConverterTypeAuthFlowInfo_lift(_ buf: RustBuffer) throws -> AuthFlowInfo { + return try FfiConverterTypeAuthFlowInfo.lift(buf) +} + +public func FfiConverterTypeAuthFlowInfo_lower(_ value: AuthFlowInfo) -> RustBuffer { + return FfiConverterTypeAuthFlowInfo.lower(value) +} + + +public struct ListItem { + public var name: String + public var isDirectory: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + name: String, + isDirectory: Bool) { + self.name = name + self.isDirectory = isDirectory + } +} + + +extension ListItem: Equatable, Hashable { + public static func ==(lhs: ListItem, rhs: ListItem) -> Bool { + if lhs.name != rhs.name { + return false + } + if lhs.isDirectory != rhs.isDirectory { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(name) + hasher.combine(isDirectory) + } +} + + +public struct FfiConverterTypeListItem: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ListItem { + return + try ListItem( + name: FfiConverterString.read(from: &buf), + isDirectory: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: ListItem, into buf: inout [UInt8]) { + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.isDirectory, into: &buf) + } +} + + +public func FfiConverterTypeListItem_lift(_ buf: RustBuffer) throws -> ListItem { + return try FfiConverterTypeListItem.lift(buf) +} + +public func FfiConverterTypeListItem_lower(_ value: ListItem) -> RustBuffer { + return FfiConverterTypeListItem.lower(value) +} + + +public struct SessionInfo { + public var pubkey: String + public var sessionSecret: String? + public var capabilities: [String] + public var createdAt: UInt64 + public var expiresAt: UInt64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + pubkey: String, + sessionSecret: String?, + capabilities: [String], + createdAt: UInt64, + expiresAt: UInt64?) { + self.pubkey = pubkey + self.sessionSecret = sessionSecret + self.capabilities = capabilities + self.createdAt = createdAt + self.expiresAt = expiresAt + } +} + + +extension SessionInfo: Equatable, Hashable { + public static func ==(lhs: SessionInfo, rhs: SessionInfo) -> Bool { + if lhs.pubkey != rhs.pubkey { + return false + } + if lhs.sessionSecret != rhs.sessionSecret { + return false + } + if lhs.capabilities != rhs.capabilities { + return false + } + if lhs.createdAt != rhs.createdAt { + return false + } + if lhs.expiresAt != rhs.expiresAt { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(pubkey) + hasher.combine(sessionSecret) + hasher.combine(capabilities) + hasher.combine(createdAt) + hasher.combine(expiresAt) + } +} + + +public struct FfiConverterTypeSessionInfo: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SessionInfo { + return + try SessionInfo( + pubkey: FfiConverterString.read(from: &buf), + sessionSecret: FfiConverterOptionString.read(from: &buf), + capabilities: FfiConverterSequenceString.read(from: &buf), + createdAt: FfiConverterUInt64.read(from: &buf), + expiresAt: FfiConverterOptionUInt64.read(from: &buf) + ) + } + + public static func write(_ value: SessionInfo, into buf: inout [UInt8]) { + FfiConverterString.write(value.pubkey, into: &buf) + FfiConverterOptionString.write(value.sessionSecret, into: &buf) + FfiConverterSequenceString.write(value.capabilities, into: &buf) + FfiConverterUInt64.write(value.createdAt, into: &buf) + FfiConverterOptionUInt64.write(value.expiresAt, into: &buf) + } +} + + +public func FfiConverterTypeSessionInfo_lift(_ buf: RustBuffer) throws -> SessionInfo { + return try FfiConverterTypeSessionInfo.lift(buf) +} + +public func FfiConverterTypeSessionInfo_lower(_ value: SessionInfo) -> RustBuffer { + return FfiConverterTypeSessionInfo.lower(value) +} + + +public struct SignupOptions { + public var capabilities: [String]? + public var signupToken: UInt64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init( + capabilities: [String]?, + signupToken: UInt64?) { + self.capabilities = capabilities + self.signupToken = signupToken + } +} + + +extension SignupOptions: Equatable, Hashable { + public static func ==(lhs: SignupOptions, rhs: SignupOptions) -> Bool { + if lhs.capabilities != rhs.capabilities { + return false + } + if lhs.signupToken != rhs.signupToken { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(capabilities) + hasher.combine(signupToken) + } +} + + +public struct FfiConverterTypeSignupOptions: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SignupOptions { + return + try SignupOptions( + capabilities: FfiConverterOptionSequenceString.read(from: &buf), + signupToken: FfiConverterOptionUInt64.read(from: &buf) + ) + } + + public static func write(_ value: SignupOptions, into buf: inout [UInt8]) { + FfiConverterOptionSequenceString.write(value.capabilities, into: &buf) + FfiConverterOptionUInt64.write(value.signupToken, into: &buf) + } +} + + +public func FfiConverterTypeSignupOptions_lift(_ buf: RustBuffer) throws -> SignupOptions { + return try FfiConverterTypeSignupOptions.lift(buf) +} + +public func FfiConverterTypeSignupOptions_lower(_ value: SignupOptions) -> RustBuffer { + return FfiConverterTypeSignupOptions.lower(value) +} + + +public enum PubkyError { + + + + case Auth(message: String) + + case Request(message: String) + + case Build(message: String) + + case InvalidInput(message: String) + + case Network(message: String) + + case Unknown(message: String) + + + fileprivate static func uniffiErrorHandler(_ error: RustBuffer) throws -> Error { + return try FfiConverterTypePubkyError.lift(error) + } +} + + +public struct FfiConverterTypePubkyError: FfiConverterRustBuffer { + typealias SwiftType = PubkyError + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PubkyError { + let variant: Int32 = try readInt(&buf) + switch variant { + + + + + case 1: return .Auth( + message: try FfiConverterString.read(from: &buf) + ) + + case 2: return .Request( + message: try FfiConverterString.read(from: &buf) + ) + + case 3: return .Build( + message: try FfiConverterString.read(from: &buf) + ) + + case 4: return .InvalidInput( + message: try FfiConverterString.read(from: &buf) + ) + + case 5: return .Network( + message: try FfiConverterString.read(from: &buf) + ) + + case 6: return .Unknown( + message: try FfiConverterString.read(from: &buf) + ) + + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: PubkyError, into buf: inout [UInt8]) { + switch value { + + + + + case .Auth(_ /* message is ignored*/): + writeInt(&buf, Int32(1)) + case .Request(_ /* message is ignored*/): + writeInt(&buf, Int32(2)) + case .Build(_ /* message is ignored*/): + writeInt(&buf, Int32(3)) + case .InvalidInput(_ /* message is ignored*/): + writeInt(&buf, Int32(4)) + case .Network(_ /* message is ignored*/): + writeInt(&buf, Int32(5)) + case .Unknown(_ /* message is ignored*/): + writeInt(&buf, Int32(6)) + + + } + } +} + + +extension PubkyError: Equatable, Hashable {} + +extension PubkyError: Error { } + +fileprivate struct FfiConverterOptionUInt64: FfiConverterRustBuffer { + typealias SwiftType = UInt64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterUInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterUInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +fileprivate struct FfiConverterOptionString: FfiConverterRustBuffer { + typealias SwiftType = String? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterString.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +fileprivate struct FfiConverterOptionTypeSignupOptions: FfiConverterRustBuffer { + typealias SwiftType = SignupOptions? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterTypeSignupOptions.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterTypeSignupOptions.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +fileprivate struct FfiConverterOptionSequenceString: FfiConverterRustBuffer { + typealias SwiftType = [String]? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterSequenceString.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterSequenceString.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + +fileprivate struct FfiConverterSequenceUInt8: FfiConverterRustBuffer { + typealias SwiftType = [UInt8] + + public static func write(_ value: [UInt8], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterUInt8.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [UInt8] { + let len: Int32 = try readInt(&buf) + var seq = [UInt8]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterUInt8.read(from: &buf)) + } + return seq + } +} + +fileprivate struct FfiConverterSequenceString: FfiConverterRustBuffer { + typealias SwiftType = [String] + + public static func write(_ value: [String], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterString.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [String] { + let len: Int32 = try readInt(&buf) + var seq = [String]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterString.read(from: &buf)) + } + return seq + } +} + +fileprivate struct FfiConverterSequenceTypeListItem: FfiConverterRustBuffer { + typealias SwiftType = [ListItem] + + public static func write(_ value: [ListItem], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeListItem.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [ListItem] { + let len: Int32 = try readInt(&buf) + var seq = [ListItem]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeListItem.read(from: &buf)) + } + return seq + } +} + +private enum InitializationResult { + case ok + case contractVersionMismatch + case apiChecksumMismatch +} +// Use a global variables to perform the versioning checks. Swift ensures that +// the code inside is only computed once. +private var initializationResult: InitializationResult { + // Get the bindings contract version from our ComponentInterface + let bindings_contract_version = 25 + // Get the scaffolding contract version by calling the into the dylib + let scaffolding_contract_version = ffi_pubky_uniffi_contract_version() + if bindings_contract_version != scaffolding_contract_version { + return InitializationResult.contractVersionMismatch + } + if (uniffi_pubky_checksum_method_keyprovider_secret_key() != 27423) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_pubkysession_info() != 16134) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_pubkysession_signout() != 31266) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_pubkysession_storage() != 63613) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_publicstorage_get() != 32316) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_publicstorage_list() != 4306) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sdk_await_approval() != 35272) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sdk_public_storage() != 16815) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sdk_signin() != 31462) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sdk_signup() != 51229) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sdk_start_auth_flow() != 2146) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sessionstorage_delete() != 52688) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sessionstorage_get() != 49563) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sessionstorage_list() != 33623) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_method_sessionstorage_put() != 46158) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_pubky_checksum_constructor_sdk_new() != 55923) { + return InitializationResult.apiChecksumMismatch + } + + uniffiCallbackInitKeyProvider() + return InitializationResult.ok +} + +private func uniffiEnsureInitialized() { + switch initializationResult { + case .ok: + break + case .contractVersionMismatch: + fatalError("UniFFI contract version mismatch: try cleaning and rebuilding your project") + case .apiChecksumMismatch: + fatalError("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } +} \ No newline at end of file diff --git a/Bitkit/PaykitIntegration/Services/PubkySDKService.swift b/Bitkit/PaykitIntegration/Services/PubkySDKService.swift new file mode 100644 index 00000000..9634c91b --- /dev/null +++ b/Bitkit/PaykitIntegration/Services/PubkySDKService.swift @@ -0,0 +1,544 @@ +// +// PubkySDKService.swift +// Bitkit +// +// Service for Pubky SDK operations using real UniFFI bindings +// Provides direct homeserver access for profile/follows fetching +// + +import Foundation + +// MARK: - PubkySDKService + +/// Service for direct Pubky homeserver operations using real FFI bindings +public final class PubkySDKService { + + // MARK: - Singleton + + public static let shared = PubkySDKService() + + // MARK: - Properties + + private let keychainStorage = PaykitKeychainStorage.shared + private var sdk: Sdk? + private var sessionCache: [String: PubkySession] = [:] + private var legacySessionCache: [String: LegacyPubkySession] = [:] // For compatibility with existing code + private let lock = NSLock() + + // MARK: - Configuration + + /// Current homeserver pubkey + public private(set) var homeserver: String = PubkyConfig.defaultHomeserver + + /// Profile cache to avoid repeated fetches + private var profileCache: [String: CachedProfile] = [:] + private let profileCacheTTL: TimeInterval = 300 // 5 minutes + + /// Follows cache + private var followsCache: [String: CachedFollows] = [:] + private let followsCacheTTL: TimeInterval = 300 // 5 minutes + + // MARK: - Initialization + + private init() { + do { + sdk = try Sdk() + Logger.info("PubkySDKService initialized with real FFI SDK", context: "PubkySDKService") + } catch { + Logger.error("Failed to initialize FFI SDK: \(error)", context: "PubkySDKService") + } + } + + // MARK: - Public API + + /// Configure the service with a homeserver + /// - Parameter homeserver: The homeserver pubkey (defaults to production) + public func configure(homeserver: String? = nil) { + self.homeserver = homeserver ?? PubkyConfig.defaultHomeserver + Logger.info("PubkySDKService configured with homeserver: \(self.homeserver)", context: "PubkySDKService") + } + + /// Sign in to homeserver using a key provider + /// - Parameters: + /// - secretKey: The 32-byte secret key + /// - homeserver: The homeserver pubkey (uses default if nil) + /// - Returns: Session info + public func signin(secretKey: Data, homeserver: String? = nil) async throws -> LegacyPubkySession { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let keyProvider = SecretKeyProvider(secretKey: secretKey) + let hs = homeserver ?? self.homeserver + + let ffiSession = try await sdk.signin(keyProvider: keyProvider, homeserver: hs) + + lock.lock() + defer { lock.unlock() } + + let info = ffiSession.info() + sessionCache[info.pubkey] = ffiSession + + // Create compatible LegacyPubkySession + let session = LegacyPubkySession( + pubkey: info.pubkey, + sessionSecret: info.sessionSecret ?? "", + capabilities: info.capabilities, + expiresAt: info.expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + legacySessionCache[info.pubkey] = session + persistSession(session) + + Logger.info("Signed in as \(info.pubkey.prefix(12))...", context: "PubkySDKService") + return session + } + + /// Sign up to homeserver + /// - Parameters: + /// - secretKey: The 32-byte secret key + /// - homeserver: The homeserver pubkey + /// - signupToken: Optional signup token + /// - Returns: Session info + public func signup(secretKey: Data, homeserver: String? = nil, signupToken: UInt64? = nil) async throws -> LegacyPubkySession { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let keyProvider = SecretKeyProvider(secretKey: secretKey) + let hs = homeserver ?? self.homeserver + + var options: SignupOptions? = nil + if let token = signupToken { + options = SignupOptions(capabilities: nil, signupToken: token) + } + + let ffiSession = try await sdk.signup(keyProvider: keyProvider, homeserver: hs, options: options) + + lock.lock() + defer { lock.unlock() } + + let info = ffiSession.info() + sessionCache[info.pubkey] = ffiSession + + // Create compatible LegacyPubkySession + let session = LegacyPubkySession( + pubkey: info.pubkey, + sessionSecret: info.sessionSecret ?? "", + capabilities: info.capabilities, + expiresAt: info.expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + legacySessionCache[info.pubkey] = session + persistSession(session) + + Logger.info("Signed up as \(info.pubkey.prefix(12))...", context: "PubkySDKService") + return session + } + + /// Start auth flow for QR/deeplink authentication + /// - Parameter capabilities: List of capability paths + /// - Returns: Auth flow info with authorization URL + public func startAuthFlow(capabilities: [String]) throws -> (authorizationUrl: String, requestId: String) { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let flowInfo = try sdk.startAuthFlow(capabilities: capabilities) + return (flowInfo.authorizationUrl, flowInfo.requestId) + } + + /// Await approval of auth flow + /// - Parameter requestId: The request ID from startAuthFlow + /// - Returns: Session after approval + public func awaitApproval(requestId: String) async throws -> LegacyPubkySession { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let ffiSession = try await sdk.awaitApproval(requestId: requestId) + + lock.lock() + defer { lock.unlock() } + + let info = ffiSession.info() + sessionCache[info.pubkey] = ffiSession + + // Create compatible LegacyPubkySession + let session = LegacyPubkySession( + pubkey: info.pubkey, + sessionSecret: info.sessionSecret ?? "", + capabilities: info.capabilities, + expiresAt: info.expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0)) } + ) + legacySessionCache[info.pubkey] = session + persistSession(session) + + Logger.info("Auth flow approved for \(info.pubkey.prefix(12))...", context: "PubkySDKService") + return session + } + + /// Set a session from Pubky-ring callback (for compatibility) + /// - Parameter session: The session to cache and persist + public func setSession(_ session: LegacyPubkySession) { + lock.lock() + defer { lock.unlock() } + + legacySessionCache[session.pubkey] = session + persistSession(session) + + Logger.info("Session set for pubkey: \(session.pubkey.prefix(12))...", context: "PubkySDKService") + } + + /// Get cached session for a pubkey + /// - Parameter pubkey: The pubkey to get session for + /// - Returns: The session if available + public func getSession(for pubkey: String) -> LegacyPubkySession? { + lock.lock() + defer { lock.unlock() } + return legacySessionCache[pubkey] + } + + /// Check if we have an active session + public var hasActiveSession: Bool { + lock.lock() + defer { lock.unlock() } + return !legacySessionCache.isEmpty || !sessionCache.isEmpty + } + + /// Get the current active session (first available) + public var activeSession: LegacyPubkySession? { + lock.lock() + defer { lock.unlock() } + return legacySessionCache.values.first + } + + // MARK: - Profile Operations + + /// Fetch a user's profile from their homeserver + /// - Parameters: + /// - pubkey: The user's public key + /// - app: The app namespace (default: pubky.app) + /// - Returns: The user's profile + public func fetchProfile(pubkey: String, app: String = "pubky.app") async throws -> SDKProfile { + // Check cache first + if let cached = profileCache[pubkey], !cached.isExpired(ttl: profileCacheTTL) { + Logger.debug("Profile cache hit for \(pubkey.prefix(12))...", context: "PubkySDKService") + return cached.profile + } + + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let profileUri = "pubky://\(pubkey)/pub/\(app)/profile.json" + Logger.debug("Fetching profile from \(profileUri)", context: "PubkySDKService") + + let publicStorage = sdk.publicStorage() + let data = try await publicStorage.get(uri: profileUri) + + let profile = try JSONDecoder().decode(SDKProfile.self, from: Data(data)) + + // Cache the result + profileCache[pubkey] = CachedProfile(profile: profile, fetchedAt: Date()) + + Logger.info("Fetched profile for \(pubkey.prefix(12))...: \(profile.name ?? "unnamed")", context: "PubkySDKService") + return profile + } + + /// Fetch a user's follows list from their homeserver + /// - Parameters: + /// - pubkey: The user's public key + /// - app: The app namespace (default: pubky.app) + /// - Returns: List of followed pubkeys + public func fetchFollows(pubkey: String, app: String = "pubky.app") async throws -> [String] { + // Check cache first + if let cached = followsCache[pubkey], !cached.isExpired(ttl: followsCacheTTL) { + Logger.debug("Follows cache hit for \(pubkey.prefix(12))...", context: "PubkySDKService") + return cached.follows + } + + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let followsUri = "pubky://\(pubkey)/pub/\(app)/follows/" + Logger.debug("Fetching follows from \(followsUri)", context: "PubkySDKService") + + let publicStorage = sdk.publicStorage() + let items = try await publicStorage.list(uri: followsUri) + + // Extract pubkeys from entry names + let follows = items.compactMap { item -> String? in + // Remove any path prefix to get just the pubkey + return item.name.isEmpty ? nil : item.name + } + + // Cache the result + followsCache[pubkey] = CachedFollows(follows: follows, fetchedAt: Date()) + + Logger.info("Fetched \(follows.count) follows for \(pubkey.prefix(12))...", context: "PubkySDKService") + return follows + } + + // MARK: - Storage Operations + + /// Get data from homeserver (public read) + /// - Parameter uri: The pubky:// URI + /// - Returns: The data if found + public func get(uri: String) async throws -> Data? { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let publicStorage = sdk.publicStorage() + do { + let data = try await publicStorage.get(uri: uri) + return Data(data) + } catch { + // Return nil for not found + return nil + } + } + + /// Put data to homeserver (requires session) + /// - Parameters: + /// - path: The storage path + /// - data: The data to store + /// - pubkey: The owner pubkey (must have active session) + public func put(path: String, data: Data, pubkey: String) async throws { + lock.lock() + let ffiSession = sessionCache[pubkey] + lock.unlock() + + guard let session = ffiSession else { + throw PubkySDKError.noSession + } + + let storage = session.storage() + try await storage.put(path: path, content: [UInt8](data)) + + Logger.debug("Put data to \(path)", context: "PubkySDKService") + } + + /// Delete data from homeserver (requires session) + /// - Parameters: + /// - path: The storage path + /// - pubkey: The owner pubkey (must have active session) + public func delete(path: String, pubkey: String) async throws { + lock.lock() + let ffiSession = sessionCache[pubkey] + lock.unlock() + + guard let session = ffiSession else { + throw PubkySDKError.noSession + } + + let storage = session.storage() + try await storage.delete(path: path) + + Logger.debug("Deleted \(path)", context: "PubkySDKService") + } + + /// List directory contents + /// - Parameter uri: The pubky:// URI + /// - Returns: List of items + public func listDirectory(uri: String) async throws -> [ListItem] { + guard let sdk = sdk else { + throw PubkySDKError.notConfigured + } + + let publicStorage = sdk.publicStorage() + return try await publicStorage.list(uri: uri) + } + + // MARK: - Session Persistence + + /// Restore sessions from keychain on app launch + public func restoreSessions() { + lock.lock() + defer { lock.unlock() } + + let sessionKeys = keychainStorage.listKeys(withPrefix: "pubky.session.") + + for key in sessionKeys { + do { + guard let data = keychainStorage.get(key: key) else { continue } + let session = try JSONDecoder().decode(LegacyPubkySession.self, from: data) + + // Check if session is expired + if let expiresAt = session.expiresAt, expiresAt < Date() { + Logger.info("Session expired for \(session.pubkey.prefix(12))..., removing", context: "PubkySDKService") + keychainStorage.deleteQuietly(key: key) + continue + } + + legacySessionCache[session.pubkey] = session + Logger.info("Restored session for \(session.pubkey.prefix(12))...", context: "PubkySDKService") + } catch { + Logger.error("Failed to restore session from \(key): \(error)", context: "PubkySDKService") + } + } + + Logger.info("Restored \(legacySessionCache.count) sessions from keychain", context: "PubkySDKService") + } + + /// Clear all cached sessions + public func clearSessions() { + lock.lock() + defer { lock.unlock() } + + for pubkey in legacySessionCache.keys { + keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") + } + legacySessionCache.removeAll() + sessionCache.removeAll() + + Logger.info("Cleared all sessions", context: "PubkySDKService") + } + + /// Sign out a specific session + public func signout(pubkey: String) async throws { + lock.lock() + let ffiSession = sessionCache[pubkey] + lock.unlock() + + if let session = ffiSession { + try session.signout() + } + + lock.lock() + sessionCache.removeValue(forKey: pubkey) + legacySessionCache.removeValue(forKey: pubkey) + keychainStorage.deleteQuietly(key: "pubky.session.\(pubkey)") + lock.unlock() + + Logger.info("Signed out \(pubkey.prefix(12))...", context: "PubkySDKService") + } + + /// Clear caches + public func clearCaches() { + profileCache.removeAll() + followsCache.removeAll() + Logger.debug("Cleared profile and follows caches", context: "PubkySDKService") + } + + // MARK: - Private Helpers + + private func persistSession(_ session: LegacyPubkySession) { + do { + let data = try JSONEncoder().encode(session) + keychainStorage.set(key: "pubky.session.\(session.pubkey)", value: data) + Logger.debug("Persisted session for \(session.pubkey.prefix(12))...", context: "PubkySDKService") + } catch { + Logger.error("Failed to persist session: \(error)", context: "PubkySDKService") + } + } +} + +// MARK: - Legacy Session (for compatibility with existing code) + +/// Legacy session struct for compatibility with existing Paykit code +public struct LegacyPubkySession: Codable { + public let pubkey: String + public let sessionSecret: String + public let capabilities: [String] + public let expiresAt: Date? + + public init(pubkey: String, sessionSecret: String, capabilities: [String], expiresAt: Date?) { + self.pubkey = pubkey + self.sessionSecret = sessionSecret + self.capabilities = capabilities + self.expiresAt = expiresAt + } +} + +// MARK: - Key Provider + +/// Key provider implementation for FFI +private class SecretKeyProvider: KeyProvider { + private let key: Data + + init(secretKey: Data) { + self.key = secretKey + } + + func secretKey() throws -> [UInt8] { + guard key.count == 32 else { + throw PubkyError.InvalidInput(message: "Secret key must be 32 bytes") + } + return [UInt8](key) + } +} + +// MARK: - Error Types + +public enum PubkySDKError: LocalizedError { + case notConfigured + case noSession + case fetchFailed(String) + case writeFailed(String) + case notFound(String) + case invalidData(String) + case invalidUri(String) + + public var errorDescription: String? { + switch self { + case .notConfigured: + return "PubkySDKService is not configured" + case .noSession: + return "No active session - authenticate with Pubky-ring first" + case .fetchFailed(let msg): + return "Fetch failed: \(msg)" + case .writeFailed(let msg): + return "Write failed: \(msg)" + case .notFound(let msg): + return "Not found: \(msg)" + case .invalidData(let msg): + return "Invalid data: \(msg)" + case .invalidUri(let msg): + return "Invalid URI: \(msg)" + } + } +} + +// MARK: - Data Models + +/// Pubky SDK profile data (maps to homeserver format) +public struct SDKProfile: Codable { + public let name: String? + public let bio: String? + public let image: String? + public let links: [SDKProfileLink]? + + public init(name: String? = nil, bio: String? = nil, image: String? = nil, links: [SDKProfileLink]? = nil) { + self.name = name + self.bio = bio + self.image = image + self.links = links + } +} + +/// SDK Profile link +public struct SDKProfileLink: Codable { + public let title: String + public let url: String +} + +// MARK: - Cache Types + +private struct CachedProfile { + let profile: SDKProfile + let fetchedAt: Date + + func isExpired(ttl: TimeInterval) -> Bool { + return Date().timeIntervalSince(fetchedAt) > ttl + } +} + +private struct CachedFollows { + let follows: [String] + let fetchedAt: Date + + func isExpired(ttl: TimeInterval) -> Bool { + return Date().timeIntervalSince(fetchedAt) > ttl + } +} diff --git a/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift b/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift index 3054ad24..952909ba 100644 --- a/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift +++ b/Bitkit/PaykitIntegration/Services/SubscriptionBackgroundService.swift @@ -170,7 +170,7 @@ public class SubscriptionBackgroundService { do { let checkResult = try SpendingLimitManager.shared.wouldExceedLimit( peerPubkey: subscription.providerPubkey, - amountSats: subscription.amountSats + amountSats: Int64(subscription.amountSats) ) if checkResult.wouldExceed { @@ -248,7 +248,7 @@ public class SubscriptionBackgroundService { throw SubscriptionError.paymentFailed(errorMessage) } } catch let error as PaykitPaymentError { - Logger.error("SubscriptionBackgroundService: Payment execution failed: \(error.localizedDescription ?? "Unknown")", context: "SubscriptionBackgroundService") + Logger.error("SubscriptionBackgroundService: Payment execution failed: \(error.localizedDescription)", context: "SubscriptionBackgroundService") await sendPaymentFailedNotification(subscription: subscription, reason: error.userMessage) throw error } catch { diff --git a/Bitkit/PaykitIntegration/Storage/PaykitKeychainStorage.swift b/Bitkit/PaykitIntegration/Storage/PaykitKeychainStorage.swift index ef8f33be..4f5e70fe 100644 --- a/Bitkit/PaykitIntegration/Storage/PaykitKeychainStorage.swift +++ b/Bitkit/PaykitIntegration/Storage/PaykitKeychainStorage.swift @@ -11,6 +11,8 @@ import Foundation /// Uses generic password items with custom account names public class PaykitKeychainStorage { + public static let shared = PaykitKeychainStorage() + private let serviceIdentifier = "to.bitkit.paykit" public init() {} @@ -95,6 +97,71 @@ public class PaykitKeychainStorage { return false } } + + // MARK: - Convenience Methods + + /// Store data using set/get naming convention + public func set(key: String, value: Data) { + do { + try store(key: key, data: value) + } catch { + Logger.error("Failed to set keychain value: \(error)", context: "PaykitKeychainStorage") + } + } + + /// Get data using set/get naming convention + public func get(key: String) -> Data? { + do { + return try retrieve(key: key) + } catch { + Logger.error("Failed to get keychain value: \(error)", context: "PaykitKeychainStorage") + return nil + } + } + + /// Delete without throwing (convenience method) + public func deleteQuietly(key: String) { + do { + try delete(key: key) as Void + } catch { + Logger.error("Failed to delete keychain value: \(error)", context: "PaykitKeychainStorage") + } + } + + /// List all keys with a given prefix + /// - Parameter prefix: The key prefix to filter by + /// - Returns: Array of matching keys + public func listKeys(withPrefix prefix: String) -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceIdentifier, + kSecReturnAttributes as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitAll, + kSecAttrAccessGroup as String: Env.keychainGroup, + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == noErr else { + if status == errSecItemNotFound { + return [] + } + Logger.error("Failed to list keychain items, status: \(status)", context: "PaykitKeychainStorage") + return [] + } + + guard let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { item -> String? in + guard let account = item[kSecAttrAccount as String] as? String else { + return nil + } + return account.hasPrefix(prefix) ? account : nil + } + } } enum PaykitStorageError: LocalizedError { diff --git a/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift b/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift index 44e1da35..c32aa0b3 100644 --- a/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift +++ b/Bitkit/PaykitIntegration/ViewModels/AutoPayViewModel.swift @@ -177,8 +177,8 @@ class AutoPayViewModel: ObservableObject { // MARK: - History Entry Model -struct AutoPayHistoryEntry: Identifiable, Codable { - let id: String +public struct AutoPayHistoryEntry: Identifiable, Codable { + public let id: String let peerPubkey: String let peerName: String let amount: Int64 diff --git a/Bitkit/PaykitIntegration/ViewModels/DashboardViewModel.swift b/Bitkit/PaykitIntegration/ViewModels/DashboardViewModel.swift index 57c86f1a..5314b765 100644 --- a/Bitkit/PaykitIntegration/ViewModels/DashboardViewModel.swift +++ b/Bitkit/PaykitIntegration/ViewModels/DashboardViewModel.swift @@ -23,6 +23,7 @@ class DashboardViewModel: ObservableObject { @Published var activeSubscriptions: Int = 0 @Published var pendingRequests: Int = 0 @Published var publishedMethodsCount: Int = 0 + @Published var sessionCount: Int = 0 private let receiptStorage: ReceiptStorage private let contactStorage: ContactStorage @@ -63,6 +64,9 @@ class DashboardViewModel: ObservableObject { // Load Payment Requests count pendingRequests = paymentRequestStorage.pendingCount() + // Load Session count + sessionCount = PubkyRingBridge.shared.getAllSessions().count + isLoading = false } diff --git a/Bitkit/PaykitIntegration/ViewModels/NoisePaymentViewModel.swift b/Bitkit/PaykitIntegration/ViewModels/NoisePaymentViewModel.swift index 7cd5fb78..128f7009 100644 --- a/Bitkit/PaykitIntegration/ViewModels/NoisePaymentViewModel.swift +++ b/Bitkit/PaykitIntegration/ViewModels/NoisePaymentViewModel.swift @@ -32,14 +32,15 @@ class NoisePaymentViewModel: ObservableObject { } func checkSessionStatus() { - if let session = pubkyRingBridge.getCachedSession() { + if let pubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32(), + let session = pubkyRingBridge.getCachedSession(for: pubkey) { isSessionActive = true - currentUserPubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() - hasNoiseKey = PaykitKeyManager.shared.noiseKeypairExists + currentUserPubkey = pubkey + hasNoiseKey = true } else { isSessionActive = false - currentUserPubkey = nil - hasNoiseKey = false + currentUserPubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() + hasNoiseKey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() != nil } } @@ -48,7 +49,7 @@ class NoisePaymentViewModel: ObservableObject { } func handleSessionAuthenticated(_ session: PubkySession) { - pubkyRingBridge.setCachedSession(session) + // Session is already cached by PubkyRingBridge checkSessionStatus() } @@ -99,29 +100,22 @@ class NoisePaymentViewModel: ObservableObject { func stopListening() { isListening = false - noisePaymentService.stopListening() + // NoisePaymentService doesn't have stopListening - it's stateless } func acceptIncomingRequest() async { guard let request = paymentRequest else { return } - do { - try await noisePaymentService.acceptPaymentRequest(request) - paymentRequest = nil - } catch { - errorMessage = error.localizedDescription - } + // TODO: Implement payment acceptance via PaykitPaymentService + // For now, just clear the request + paymentRequest = nil } func declineIncomingRequest() async { - guard let request = paymentRequest else { return } + guard paymentRequest != nil else { return } - do { - try await noisePaymentService.declinePaymentRequest(request) - paymentRequest = nil - } catch { - errorMessage = error.localizedDescription - } + // Decline by simply clearing the request + paymentRequest = nil } } diff --git a/Bitkit/PaykitIntegration/ViewModels/SubscriptionsViewModel.swift b/Bitkit/PaykitIntegration/ViewModels/SubscriptionsViewModel.swift index dcc90ff7..e85de30f 100644 --- a/Bitkit/PaykitIntegration/ViewModels/SubscriptionsViewModel.swift +++ b/Bitkit/PaykitIntegration/ViewModels/SubscriptionsViewModel.swift @@ -88,7 +88,7 @@ class SubscriptionsViewModel: ObservableObject { let subscription = BitkitSubscription( providerName: proposal.providerName, providerPubkey: proposal.providerPubkey, - amountSats: proposal.amountSats, + amountSats: UInt64(proposal.amountSats), currency: proposal.currency, frequency: proposal.frequency, description: proposal.description, @@ -128,8 +128,8 @@ class SubscriptionsViewModel: ObservableObject { // MARK: - Models -struct SubscriptionProposal: Identifiable, Codable { - let id: String +public struct SubscriptionProposal: Identifiable, Codable { + public let id: String let providerName: String let providerPubkey: String let amountSats: Int64 @@ -168,8 +168,8 @@ struct SubscriptionProposal: Identifiable, Codable { } } -struct SubscriptionPayment: Identifiable, Codable { - let id: String +public struct SubscriptionPayment: Identifiable, Codable { + public let id: String let subscriptionId: String let subscriptionName: String let amountSats: Int64 @@ -205,11 +205,11 @@ enum SubscriptionPaymentStatus: String, Codable { case failed = "Failed" } -struct SubscriptionSpendingLimit: Codable { - let maxAmount: Int64 - let period: SpendingLimitPeriod - var usedAmount: Int64 - let requireConfirmation: Bool +public struct SubscriptionSpendingLimit: Codable { + public let maxAmount: Int64 + public let period: SpendingLimitPeriod + public var usedAmount: Int64 + public let requireConfirmation: Bool init(maxAmount: Int64, period: SpendingLimitPeriod, usedAmount: Int64 = 0, requireConfirmation: Bool = false) { self.maxAmount = maxAmount @@ -219,7 +219,7 @@ struct SubscriptionSpendingLimit: Codable { } } -enum SpendingLimitPeriod: String, Codable { +public enum SpendingLimitPeriod: String, Codable { case daily = "day" case weekly = "week" case monthly = "month" diff --git a/Bitkit/SceneDelegate.swift b/Bitkit/SceneDelegate.swift index 9869fa08..3c22caa8 100644 --- a/Bitkit/SceneDelegate.swift +++ b/Bitkit/SceneDelegate.swift @@ -1,13 +1,16 @@ import SwiftUI import UIKit -// MARK: - Scene Delegate for Quick Actions +// MARK: - Scene Delegate for Quick Actions & URL Handling -// Handles scene lifecycle and quick actions for SwiftUI apps +// Handles scene lifecycle, quick actions, and URL callbacks for SwiftUI apps class SceneDelegate: NSObject, UIWindowSceneDelegate { // MARK: - Quick Action State var savedShortCutItem: UIApplicationShortcutItem? + + // Saved URL for when scene becomes active + var savedURL: URL? // MARK: - Scene Connection @@ -16,16 +19,44 @@ class SceneDelegate: NSObject, UIWindowSceneDelegate { if let shortcutItem = connectionOptions.shortcutItem { savedShortCutItem = shortcutItem } + + // Handle URLs passed at scene creation + if let urlContext = connectionOptions.urlContexts.first { + savedURL = urlContext.url + } } // MARK: - Scene Activation - // Handle saved quick action when scene becomes active + // Handle saved quick action and URL when scene becomes active func sceneDidBecomeActive(_ scene: UIScene) { if let shortcutItem = savedShortCutItem { handleQuickAction(shortcutItem) savedShortCutItem = nil } + + // Handle saved URL + if let url = savedURL { + handleURL(url) + savedURL = nil + } + } + + // MARK: - URL Handling + + // Handle URLs when app is already running + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + guard let urlContext = URLContexts.first else { return } + handleURL(urlContext.url) + } + + // Process URL and route to appropriate handler + private func handleURL(_ url: URL) { + // Route bitkit:// URLs to PubkyRingBridge for Paykit/Pubky-ring callbacks + if url.scheme == "bitkit" { + Logger.info("SceneDelegate: Received bitkit:// URL: \(url.absoluteString)", context: "SceneDelegate") + _ = PubkyRingBridge.shared.handleCallback(url: url) + } } // MARK: - Quick Action Handling (App Running) diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index a5d9f6a7..af1d29b9 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -29,7 +29,12 @@ public enum UnifiedActivityItem: Identifiable, Hashable { public var id: String { switch self { case .standard(let activity): - return activity.id + switch activity { + case .lightning(let ln): + return ln.id + case .onchain(let on): + return on.id + } case .paykit(let receipt): return "paykit-\(receipt.id)" } @@ -733,6 +738,8 @@ extension ActivityListViewModel { return on.isTransfer } } + case .paykit: + return [] // Paykit receipts are handled separately } } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index b8fc7529..d993cc35 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -302,21 +302,21 @@ extension AppViewModel { case let .paykit(pubkey, method, amount): Logger.info("Paykit URI: pubkey=\(pubkey), method=\(method ?? "any"), amount=\(amount?.description ?? "none")", context: "AppViewModel") // Navigate to Paykit send flow with pre-filled data - navigationViewModel.navigateTo(.paykitDashboard) + navigationViewModel.navigate(.paykitDashboard) // TODO: Pass pubkey, method, amount to the send flow toast(type: .success, title: "Paykit Contact", description: "Opening payment flow for \(pubkey.prefix(8))...") case let .paymentRequest(id): Logger.info("Payment Request URI: id=\(id)", context: "AppViewModel") // Navigate to payment request detail - navigationViewModel.navigateTo(.paykitDashboard) + navigationViewModel.navigate(.paykitDashboard) // TODO: Navigate directly to specific request toast(type: .info, title: "Payment Request", description: "Opening request \(id.prefix(8))...") case let .subscription(id): Logger.info("Subscription URI: id=\(id)", context: "AppViewModel") // Navigate to subscription detail - navigationViewModel.navigateTo(.paykitDashboard) + navigationViewModel.navigate(.paykitDashboard) // TODO: Navigate directly to specific subscription toast(type: .info, title: "Subscription", description: "Opening subscription \(id.prefix(8))...") } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index d5493705..5b06b3b8 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -105,6 +105,7 @@ enum Route: Hashable { case paykitNoisePayment case paykitPrivateEndpoints case paykitRotationSettings + case paykitSessionManagement } @MainActor diff --git a/Bitkit/Views/Paykit/ContactDiscoveryView.swift b/Bitkit/Views/Paykit/ContactDiscoveryView.swift index 77aa35a3..c4e640f5 100644 --- a/Bitkit/Views/Paykit/ContactDiscoveryView.swift +++ b/Bitkit/Views/Paykit/ContactDiscoveryView.swift @@ -20,7 +20,7 @@ struct ContactDiscoveryView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: "Discover Contacts", - trailing: AnyView( + action: AnyView( HStack(spacing: 12) { Button { showingFilters.toggle() @@ -189,16 +189,16 @@ struct ContactDiscoveryView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - FilterChip(title: "All", isSelected: filterMethod == nil) { + DiscoveryFilterChip(title: "All", isSelected: filterMethod == nil) { filterMethod = nil } - FilterChip(title: "⚡ Lightning", isSelected: filterMethod == "lightning") { + DiscoveryFilterChip(title: "⚡ Lightning", isSelected: filterMethod == "lightning") { filterMethod = "lightning" } - FilterChip(title: "₿ On-chain", isSelected: filterMethod == "onchain") { + DiscoveryFilterChip(title: "₿ On-chain", isSelected: filterMethod == "onchain") { filterMethod = "onchain" } - FilterChip(title: "📡 Noise", isSelected: filterMethod == "noise") { + DiscoveryFilterChip(title: "📡 Noise", isSelected: filterMethod == "noise") { filterMethod = "noise" } } @@ -407,7 +407,7 @@ struct ContactDetailSheet: View { .font(.largeTitle) } - HeadlineLText(contact.name ?? "Unknown") + HeadlineText(contact.name ?? "Unknown") .foregroundColor(.white) Button { @@ -716,12 +716,8 @@ class ContactDiscoveryViewModel: ObservableObject { } func checkEndpointHealth(for contact: DirectoryDiscoveredContact) async { - // Check health of all endpoints for this contact - for method in contact.supportedMethods { - await directoryService.checkEndpointHealth(pubkey: contact.pubkey, method: method) - } - - // Refresh data + // TODO: Implement endpoint health check when DirectoryService supports it + // For now, just refresh data loadFollows() } } @@ -776,3 +772,20 @@ extension DirectoryDiscoveredContact { } } +private struct DiscoveryFilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + BodySText(title) + .foregroundColor(isSelected ? .white : .textSecondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.brandAccent : Color.gray5) + .cornerRadius(16) + } + } +} + diff --git a/Bitkit/Views/Paykit/NoisePaymentView.swift b/Bitkit/Views/Paykit/NoisePaymentView.swift index 14e9980b..9e3ff86b 100644 --- a/Bitkit/Views/Paykit/NoisePaymentView.swift +++ b/Bitkit/Views/Paykit/NoisePaymentView.swift @@ -53,7 +53,7 @@ struct NoisePaymentView: View { } .sheet(isPresented: $showingContactPicker) { ContactPickerSheet { contact in - recipientPubkey = contact.pubkey + recipientPubkey = contact.publicKeyZ32 } } .sheet(isPresented: $showingPubkyRingAuth) { @@ -453,7 +453,7 @@ struct NoisePaymentView: View { } if let amountStr = request.amount { - HeadlineLText("\(amountStr) \(request.currency ?? "sats")") + HeadlineText("\(amountStr) \(request.currency ?? "sats")") .foregroundColor(.white) } @@ -516,7 +516,7 @@ struct NoisePaymentView: View { // MARK: - Contact Picker Sheet struct ContactPickerSheet: View { - let onSelect: (PaykitContact) -> Void + let onSelect: (Contact) -> Void @Environment(\.dismiss) private var dismiss @StateObject private var contactsVM = ContactsViewModel() @State private var searchText = "" @@ -542,7 +542,7 @@ struct ContactPickerSheet: View { VStack(alignment: .leading, spacing: 2) { Text(contact.name) .foregroundColor(.white) - Text(contact.pubkey.prefix(16) + "...") + Text(contact.publicKeyZ32.prefix(16) + "...") .font(.caption) .foregroundColor(.textSecondary) } @@ -568,13 +568,13 @@ struct ContactPickerSheet: View { } } - private var filteredContacts: [PaykitContact] { + private var filteredContacts: [Contact] { if searchText.isEmpty { return contactsVM.contacts } return contactsVM.contacts.filter { $0.name.localizedCaseInsensitiveContains(searchText) || - $0.pubkey.localizedCaseInsensitiveContains(searchText) + $0.publicKeyZ32.localizedCaseInsensitiveContains(searchText) } } } diff --git a/Bitkit/Views/Paykit/PaykitAutoPayView.swift b/Bitkit/Views/Paykit/PaykitAutoPayView.swift index fafb0665..01f162e8 100644 --- a/Bitkit/Views/Paykit/PaykitAutoPayView.swift +++ b/Bitkit/Views/Paykit/PaykitAutoPayView.swift @@ -564,95 +564,6 @@ struct AutoPayHistoryRow: View { return formatter.localizedString(for: date, relativeTo: Date()) } } - - private var peerLimitsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - BodyLText("Per-Peer Limits") - .foregroundColor(.textPrimary) - - Spacer() - - Button { - showingAddPeerLimit = true - } label: { - Image(systemName: "plus.circle.fill") - .foregroundColor(.brandAccent) - .font(.title3) - } - } - - if viewModel.peerLimits.isEmpty { - BodyMText("No peer limits set") - .foregroundColor(.textSecondary) - .padding(16) - .frame(maxWidth: .infinity) - .background(Color.gray6) - .cornerRadius(8) - } else { - VStack(spacing: 0) { - ForEach(viewModel.peerLimits) { limit in - PeerLimitRow(limit: limit, viewModel: viewModel) - - if limit.id != viewModel.peerLimits.last?.id { - Divider() - .background(Color.white16) - } - } - } - .background(Color.gray6) - .cornerRadius(8) - } - } - } - - private var rulesSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - BodyLText("Auto-Pay Rules") - .foregroundColor(.textPrimary) - - Spacer() - - Button { - showingAddRule = true - } label: { - Image(systemName: "plus.circle.fill") - .foregroundColor(.brandAccent) - .font(.title3) - } - } - - if viewModel.rules.isEmpty { - BodyMText("No rules set") - .foregroundColor(.textSecondary) - .padding(16) - .frame(maxWidth: .infinity) - .background(Color.gray6) - .cornerRadius(8) - } else { - VStack(spacing: 0) { - ForEach(viewModel.rules) { rule in - RuleRow(rule: rule, viewModel: viewModel) - - if rule.id != viewModel.rules.last?.id { - Divider() - .background(Color.white16) - } - } - } - .background(Color.gray6) - .cornerRadius(8) - } - } - } - - private func formatSats(_ amount: Int64) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return "\(formatter.string(from: NSNumber(value: amount)) ?? "\(amount)") sats" - } -} struct PeerLimitRow: View { let limit: StoredPeerLimit @@ -799,7 +710,10 @@ struct AddPeerLimitView: View { BodyMText("Amount:") .foregroundColor(.textSecondary) Spacer() - TextField("sats", value: $limit, format: .number) + TextField("sats", text: Binding( + get: { String(limit) }, + set: { limit = Int64($0) ?? 0 } + )) .foregroundColor(.white) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) @@ -890,7 +804,10 @@ struct AddRuleView: View { BodyMText("Max Amount:") .foregroundColor(.textSecondary) Spacer() - TextField("sats (optional)", value: $maxAmount, format: .number) + TextField("sats (optional)", text: Binding( + get: { maxAmount.map { String($0) } ?? "" }, + set: { maxAmount = Int64($0) } + )) .foregroundColor(.white) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) diff --git a/Bitkit/Views/Paykit/PaykitDashboardView.swift b/Bitkit/Views/Paykit/PaykitDashboardView.swift index b2ac1edb..de9bec9b 100644 --- a/Bitkit/Views/Paykit/PaykitDashboardView.swift +++ b/Bitkit/Views/Paykit/PaykitDashboardView.swift @@ -195,6 +195,16 @@ struct PaykitDashboardView: View { // Pubky-ring connection status pubkyRingConnectionCard + + // Session management + QuickAccessCard( + title: "Sessions", + icon: "person.badge.shield.checkmark.fill", + color: .teal, + badge: viewModel.sessionCount > 0 ? "\(viewModel.sessionCount)" : nil + ) { + navigation.navigate(.paykitSessionManagement) + } } } @@ -258,7 +268,7 @@ struct PaykitDashboardView: View { .foregroundColor(.textSecondary) BodySText("Your Paykit transactions will appear here") - .foregroundColor(.textTertiary) + .foregroundColor(.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) diff --git a/Bitkit/Views/Paykit/PaykitPaymentRequestsView.swift b/Bitkit/Views/Paykit/PaykitPaymentRequestsView.swift index ca87b651..614d6630 100644 --- a/Bitkit/Views/Paykit/PaykitPaymentRequestsView.swift +++ b/Bitkit/Views/Paykit/PaykitPaymentRequestsView.swift @@ -17,7 +17,7 @@ struct PaykitPaymentRequestsView: View { @State private var selectedStatusFilter: PaymentRequestStatus? = nil @State private var peerFilter: String = "" @State private var showingFilters = false - @State private var selectedRequest: PaymentRequest? = nil + @State private var selectedRequest: BitkitPaymentRequest? = nil @State private var showingRequestDetail = false enum RequestFilter: String, CaseIterable { @@ -31,7 +31,7 @@ struct PaykitPaymentRequestsView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: "Payment Requests", - trailing: AnyView( + action: AnyView( HStack(spacing: 16) { Button { showingFilters.toggle() @@ -114,11 +114,11 @@ struct PaykitPaymentRequestsView: View { // Status Filter ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - FilterChip(title: "Any Status", isSelected: selectedStatusFilter == nil) { + RequestFilterChip(title: "Any Status", isSelected: selectedStatusFilter == nil) { selectedStatusFilter = nil } ForEach(PaymentRequestStatus.allCases, id: \.self) { status in - FilterChip(title: status.rawValue, isSelected: selectedStatusFilter == status) { + RequestFilterChip(title: status.rawValue, isSelected: selectedStatusFilter == status) { selectedStatusFilter = status } } @@ -150,7 +150,7 @@ struct PaykitPaymentRequestsView: View { .cornerRadius(12) } - private var filteredRequests: [PaymentRequest] { + private var filteredRequests: [BitkitPaymentRequest] { var results = viewModel.requests // Direction filter @@ -235,7 +235,7 @@ struct PaykitPaymentRequestsView: View { .padding(.vertical, 40) } - private func initiatePayment(for request: PaymentRequest) { + private func initiatePayment(for request: BitkitPaymentRequest) { Task { do { let result = try await PaykitPaymentService.shared.pay( @@ -261,7 +261,7 @@ struct PaykitPaymentRequestsView: View { // MARK: - Filter Chip -struct FilterChip: View { +private struct RequestFilterChip: View { let title: String let isSelected: Bool let action: () -> Void @@ -279,7 +279,7 @@ struct FilterChip: View { } struct PaymentRequestRow: View { - let request: PaymentRequest + let request: BitkitPaymentRequest @ObservedObject var viewModel: PaymentRequestsViewModel var onTap: () -> Void = {} var onPayNow: () -> Void = {} @@ -318,12 +318,12 @@ struct PaymentRequestRow: View { } } - // Metadata preview - if let metadata = request.metadata, !metadata.isEmpty { + // Description preview (metadata-like) + if !request.description.isEmpty { HStack(spacing: 4) { Image(systemName: "doc.text") .font(.caption2) - BodySText("\(metadata.count) item\(metadata.count == 1 ? "" : "s")") + BodySText(request.description) } .foregroundColor(.brandAccent) } @@ -503,7 +503,7 @@ struct StatusBadge: View { // MARK: - Payment Request Detail Sheet struct PaymentRequestDetailSheet: View { - let request: PaymentRequest + let request: BitkitPaymentRequest @ObservedObject var viewModel: PaymentRequestsViewModel @EnvironmentObject private var app: AppViewModel @Environment(\.dismiss) private var dismiss @@ -518,8 +518,8 @@ struct PaymentRequestDetailSheet: View { peerSection methodSection - if let metadata = request.metadata, !metadata.isEmpty { - metadataSection(metadata) + if !request.description.isEmpty { + descriptionSection } if request.status == .pending { @@ -553,7 +553,7 @@ struct PaymentRequestDetailSheet: View { .foregroundColor(request.direction == .incoming ? .greenAccent : .brandAccent) } - HeadlineXLText(formatSats(request.amountSats)) + HeadlineText(formatSats(request.amountSats)) .foregroundColor(.white) if !request.description.isEmpty { @@ -695,6 +695,20 @@ struct PaymentRequestDetailSheet: View { .cornerRadius(12) } + private var descriptionSection: some View { + VStack(alignment: .leading, spacing: 8) { + BodyMBoldText("Description") + .foregroundColor(.textSecondary) + + BodyMText(request.description) + .foregroundColor(.white) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.gray6) + .cornerRadius(12) + } + private var actionsSection: some View { VStack(spacing: 12) { if request.direction == .incoming { @@ -856,7 +870,10 @@ struct CreatePaymentRequestView: View { BodyMText("Amount:") .foregroundColor(.textSecondary) Spacer() - TextField("sats", value: $amount, format: .number) + TextField("sats", text: Binding( + get: { String(amount) }, + set: { amount = Int64($0) ?? 0 } + )) .foregroundColor(.white) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) @@ -895,7 +912,7 @@ struct CreatePaymentRequestView: View { let expiresAt = Calendar.current.date(byAdding: .day, value: expiresInDays, to: Date()) let fromPubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() ?? "" - let request = PaymentRequest( + let request = BitkitPaymentRequest( id: UUID().uuidString, fromPubkey: fromPubkey, toPubkey: toPubkey, @@ -944,7 +961,7 @@ struct CreatePaymentRequestView: View { // ViewModel for Payment Requests @MainActor class PaymentRequestsViewModel: ObservableObject { - @Published var requests: [PaymentRequest] = [] + @Published var requests: [BitkitPaymentRequest] = [] @Published var isLoading = false private let storage: PaymentRequestStorage @@ -961,17 +978,17 @@ class PaymentRequestsViewModel: ObservableObject { isLoading = false } - func addRequest(_ request: PaymentRequest) throws { + func addRequest(_ request: BitkitPaymentRequest) throws { try storage.addRequest(request) loadRequests() } - func updateRequest(_ request: PaymentRequest) throws { + func updateRequest(_ request: BitkitPaymentRequest) throws { try storage.updateRequest(request) loadRequests() } - func deleteRequest(_ request: PaymentRequest) throws { + func deleteRequest(_ request: BitkitPaymentRequest) throws { try storage.deleteRequest(id: request.id) loadRequests() } diff --git a/Bitkit/Views/Paykit/PaykitSubscriptionsView.swift b/Bitkit/Views/Paykit/PaykitSubscriptionsView.swift index 55d31f77..d50ff1af 100644 --- a/Bitkit/Views/Paykit/PaykitSubscriptionsView.swift +++ b/Bitkit/Views/Paykit/PaykitSubscriptionsView.swift @@ -24,7 +24,7 @@ struct PaykitSubscriptionsView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: "Subscriptions", - trailing: AnyView( + action: AnyView( Button { viewModel.showingAddSubscription = true } label: { @@ -121,7 +121,7 @@ struct PaykitSubscriptionsView: View { VStack(alignment: .leading, spacing: 4) { BodySText("Used") .foregroundColor(.textSecondary) - HeadlineMText(formatSats(viewModel.totalSpentThisMonth)) + HeadlineText(formatSats(viewModel.totalSpentThisMonth)) .foregroundColor(.white) } @@ -130,7 +130,7 @@ struct PaykitSubscriptionsView: View { VStack(alignment: .trailing, spacing: 4) { BodySText("Remaining") .foregroundColor(.textSecondary) - HeadlineMText(formatSats(viewModel.remainingSpendingLimit)) + HeadlineText(formatSats(viewModel.remainingSpendingLimit)) .foregroundColor(.greenAccent) } } @@ -299,7 +299,7 @@ struct ProposalCard: View { Spacer() VStack(alignment: .trailing, spacing: 4) { - HeadlineMText(formatSats(proposal.amountSats)) + HeadlineText(formatSats(proposal.amountSats)) .foregroundColor(.white) BodySText("/ \(proposal.frequency)") .foregroundColor(.textSecondary) @@ -529,7 +529,7 @@ struct SubscriptionDetailSheet: View { .foregroundColor(subscription.isActive ? .greenAccent : .textSecondary) } - HeadlineLText(subscription.providerName) + HeadlineText(subscription.providerName) .foregroundColor(.white) HStack(spacing: 4) { @@ -583,7 +583,7 @@ struct SubscriptionDetailSheet: View { BodyMText("Max per \(limit.period.rawValue)") .foregroundColor(.white) Spacer() - BodyMBoldText(formatSats(limit.maxAmount)) + BodyMBoldText(formatSats(UInt64(limit.maxAmount))) .foregroundColor(.white) } @@ -602,7 +602,7 @@ struct SubscriptionDetailSheet: View { } .frame(height: 8) - BodySText("\(formatSats(limit.usedAmount)) used of \(formatSats(limit.maxAmount))") + BodySText("\(formatSats(UInt64(limit.usedAmount))) used of \(formatSats(UInt64(limit.maxAmount)))") .foregroundColor(.textSecondary) } else { Button { @@ -638,7 +638,7 @@ struct SubscriptionDetailSheet: View { BodySText(formatDate(payment.paidAt)) .foregroundColor(.textSecondary) Spacer() - BodySText(formatSats(payment.amountSats)) + BodySText(formatSats(UInt64(payment.amountSats))) .foregroundColor(.white) } } @@ -692,7 +692,7 @@ struct SubscriptionDetailSheet: View { } } - private func formatSats(_ amount: Int64) -> String { + private func formatSats(_ amount: UInt64) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal return "\(formatter.string(from: NSNumber(value: amount)) ?? "\(amount)") sats" @@ -741,7 +741,10 @@ struct EditSpendingLimitSheet: View { BodyLText("Maximum Amount") .foregroundColor(.textPrimary) - TextField("sats", value: $maxAmount, format: .number) + TextField("sats", text: Binding( + get: { String(maxAmount) }, + set: { maxAmount = Int64($0) ?? 0 } + )) .foregroundColor(.white) .keyboardType(.numberPad) .padding(12) @@ -902,7 +905,7 @@ struct SubscriptionRow: View { HStack(spacing: 2) { Image(systemName: "shield.fill") .font(.caption2) - BodySText("\(formatSats(limit.usedAmount))/\(formatSats(limit.maxAmount))") + BodySText("\(formatSats(UInt64(limit.usedAmount)))/\(formatSats(UInt64(limit.maxAmount)))") } .foregroundColor(.brandAccent) } @@ -926,7 +929,7 @@ struct SubscriptionRow: View { .padding(16) } - private func formatSats(_ amount: Int64) -> String { + private func formatSats(_ amount: UInt64) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal return "\(formatter.string(from: NSNumber(value: amount)) ?? "\(amount)")" @@ -946,7 +949,7 @@ struct AddSubscriptionView: View { @State private var providerName = "" @State private var providerPubkey = "" - @State private var amount: Int64 = 1000 + @State private var amount: UInt64 = 1000 @State private var frequency = "monthly" @State private var methodId = "lightning" @State private var description = "" @@ -983,7 +986,7 @@ struct AddSubscriptionView: View { Spacer() TextField("sats", text: Binding( get: { String(amount) }, - set: { amount = Int64($0) ?? 0 } + set: { amount = UInt64($0) ?? 0 } )) .foregroundColor(.white) .keyboardType(.numberPad) diff --git a/Bitkit/Views/Paykit/ProfileEditView.swift b/Bitkit/Views/Paykit/ProfileEditView.swift index df9dfb5e..ad0d73bc 100644 --- a/Bitkit/Views/Paykit/ProfileEditView.swift +++ b/Bitkit/Views/Paykit/ProfileEditView.swift @@ -26,14 +26,13 @@ struct ProfileEditView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: "Edit Profile", - leftButton: NavigationBarButton( - type: .close, - action: { dismiss() } - ), - rightButton: hasChanges ? NavigationBarButton( - type: .textButton(text: "Save"), - action: { Task { await saveProfile() } } - ) : nil + action: hasChanges ? AnyView( + Button("Save") { + Task { await saveProfile() } + } + .foregroundColor(.brandAccent) + ) : nil, + onBack: { dismiss() } ) if isLoading { @@ -74,7 +73,7 @@ struct ProfileEditView: View { BodySText("Bio") .foregroundColor(.textSecondary) Spacer() - BodyXSText("\(bio.count)/160") + Text("\(bio.count)/160").font(.caption2) .foregroundColor(bio.count > 160 ? .red : .textSecondary) } @@ -146,7 +145,7 @@ struct ProfileEditView: View { } } } - .background(Color.gray8) + .background(Color.gray6) .onAppear { Task { await loadCurrentProfile() @@ -175,7 +174,8 @@ struct ProfileEditView: View { } } - BodyXSText("Tap to change avatar") + Text("Tap to change avatar") + .font(.caption) .foregroundColor(.textSecondary) } Spacer() diff --git a/Bitkit/Views/Paykit/ProfileImportView.swift b/Bitkit/Views/Paykit/ProfileImportView.swift index 4bbaad3a..8891c2aa 100644 --- a/Bitkit/Views/Paykit/ProfileImportView.swift +++ b/Bitkit/Views/Paykit/ProfileImportView.swift @@ -19,10 +19,7 @@ struct ProfileImportView: View { VStack(alignment: .leading, spacing: 0) { NavigationBar( title: "Import Profile", - leftButton: NavigationBarButton( - type: .close, - action: { dismiss() } - ) + onBack: { dismiss() } ) ScrollView { @@ -124,7 +121,7 @@ struct ProfileImportView: View { .padding(.top, 16) } } - .background(Color.gray8) + .background(Color.gray6) .onAppear { // Pre-fill with current pubkey if available if let currentPubkey = PaykitKeyManager.shared.getCurrentPublicKeyZ32() { @@ -230,7 +227,8 @@ struct ProfilePreviewCard: View { VStack(alignment: .leading) { BodySText(link.title) .foregroundColor(.white) - BodyXSText(link.url) + Text(link.url) + .font(.caption) .foregroundColor(.textSecondary) } } diff --git a/Bitkit/Views/Paykit/SessionManagementView.swift b/Bitkit/Views/Paykit/SessionManagementView.swift new file mode 100644 index 00000000..b5dff407 --- /dev/null +++ b/Bitkit/Views/Paykit/SessionManagementView.swift @@ -0,0 +1,486 @@ +// +// SessionManagementView.swift +// Bitkit +// +// View for managing Pubky sessions - viewing, exporting, and removing sessions +// + +import SwiftUI + +struct SessionManagementView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel = SessionManagementViewModel() + @State private var showExportSheet = false + @State private var showImportSheet = false + @State private var showDeleteConfirmation = false + @State private var selectedSession: PubkySession? + @State private var importText = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar( + title: "Session Management", + showBackButton: true, + action: AnyView(menuButton), + onBack: { dismiss() } + ) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 24) { + // Device Info Section + deviceInfoSection + + // Active Sessions Section + sessionsSection + + // Backup Section + backupSection + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 32) + } + } + .navigationBarHidden(true) + .onAppear { + viewModel.loadSessions() + } + .sheet(isPresented: $showExportSheet) { + ExportBackupSheet( + backupJSON: viewModel.exportBackupJSON(), + onDismiss: { showExportSheet = false } + ) + } + .sheet(isPresented: $showImportSheet) { + ImportBackupSheet( + importText: $importText, + onImport: { overwriteDeviceId in + viewModel.importBackup(jsonString: importText, overwriteDeviceId: overwriteDeviceId) + showImportSheet = false + importText = "" + }, + onDismiss: { showImportSheet = false } + ) + } + .alert("Remove Session", isPresented: $showDeleteConfirmation, presenting: selectedSession) { session in + Button("Cancel", role: .cancel) { } + Button("Remove", role: .destructive) { + viewModel.removeSession(pubkey: session.pubkey) + } + } message: { session in + Text("Are you sure you want to remove this session for \(session.pubkey.prefix(12))...?") + } + } + + private var menuButton: some View { + Menu { + Button { + showImportSheet = true + } label: { + Label("Import Backup", systemImage: "square.and.arrow.down") + } + + Button { + viewModel.clearAllSessions() + } label: { + Label("Clear All Sessions", systemImage: "trash") + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundColor(.white) + .font(.title3) + } + } + + private var deviceInfoSection: some View { + VStack(alignment: .leading, spacing: 12) { + BodyLText("Device Info") + .foregroundColor(.textSecondary) + + VStack(alignment: .leading, spacing: 8) { + HStack { + BodyMText("Device ID") + .foregroundColor(.textSecondary) + Spacer() + BodyMText(viewModel.deviceId.prefix(8) + "...") + .foregroundColor(.white) + } + + HStack { + BodyMText("Current Epoch") + .foregroundColor(.textSecondary) + Spacer() + BodyMText("\(viewModel.currentEpoch)") + .foregroundColor(.white) + } + + HStack { + BodyMText("Cached Keys") + .foregroundColor(.textSecondary) + Spacer() + BodyMText("\(viewModel.cachedKeyCount)") + .foregroundColor(.white) + } + } + .padding(16) + .background(Color.gray6) + .cornerRadius(8) + } + } + + private var sessionsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + BodyLText("Active Sessions") + .foregroundColor(.textSecondary) + + Spacer() + + BodySText("\(viewModel.sessions.count) session(s)") + .foregroundColor(.textSecondary) + } + + if viewModel.sessions.isEmpty { + VStack(spacing: 12) { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 32)) + .foregroundColor(.textSecondary) + + BodyMText("No active sessions") + .foregroundColor(.textSecondary) + + BodySText("Connect to Pubky-ring to create a session") + .foregroundColor(.textSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 32) + .background(Color.gray6) + .cornerRadius(8) + } else { + VStack(spacing: 0) { + ForEach(viewModel.sessions, id: \.pubkey) { session in + SessionRow(session: session, onRemove: { + selectedSession = session + showDeleteConfirmation = true + }) + + if session.pubkey != viewModel.sessions.last?.pubkey { + Divider() + .background(Color.white16) + } + } + } + .background(Color.gray6) + .cornerRadius(8) + } + } + } + + private var backupSection: some View { + VStack(alignment: .leading, spacing: 12) { + BodyLText("Backup & Restore") + .foregroundColor(.textSecondary) + + Button { + showExportSheet = true + } label: { + HStack(spacing: 12) { + Image(systemName: "square.and.arrow.up") + .foregroundColor(.brandAccent) + .font(.title3) + + VStack(alignment: .leading, spacing: 4) { + BodyMBoldText("Export Backup") + .foregroundColor(.white) + + BodySText("Save sessions and keys to restore later") + .foregroundColor(.textSecondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + .font(.caption) + } + .padding(16) + .background(Color.gray6) + .cornerRadius(8) + } + + Button { + showImportSheet = true + } label: { + HStack(spacing: 12) { + Image(systemName: "square.and.arrow.down") + .foregroundColor(.blue) + .font(.title3) + + VStack(alignment: .leading, spacing: 4) { + BodyMBoldText("Import Backup") + .foregroundColor(.white) + + BodySText("Restore sessions and keys from backup") + .foregroundColor(.textSecondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundColor(.textSecondary) + .font(.caption) + } + .padding(16) + .background(Color.gray6) + .cornerRadius(8) + } + } + } +} + +// MARK: - Session Row + +struct SessionRow: View { + let session: PubkySession + let onRemove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + BodyMBoldText("Pubkey") + .foregroundColor(.textSecondary) + + BodyMText(session.pubkey.prefix(20) + "...") + .foregroundColor(.white) + } + + Spacer() + + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red.opacity(0.7)) + .font(.title2) + } + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + BodySText("Created") + .foregroundColor(.textSecondary) + + BodySText(formatDate(session.createdAt)) + .foregroundColor(.white) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 4) { + BodySText("Capabilities") + .foregroundColor(.textSecondary) + + BodySText(session.capabilities.isEmpty ? "None" : session.capabilities.joined(separator: ", ")) + .foregroundColor(.white) + } + } + + if let expiresAt = session.expiresAt { + HStack { + BodySText("Expires") + .foregroundColor(.textSecondary) + + BodySText(formatDate(expiresAt)) + .foregroundColor(session.isExpired ? .red : .orange) + } + } + } + .padding(16) + } + + private func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return formatter.string(from: date) + } +} + +// MARK: - Export Backup Sheet + +struct ExportBackupSheet: View { + let backupJSON: String + let onDismiss: () -> Void + + @State private var copied = false + + var body: some View { + VStack(spacing: 24) { + HStack { + TitleText("Export Backup", textColor: .white) + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.textSecondary) + .font(.title2) + } + } + + ScrollView { + Text(backupJSON) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.white) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.gray6) + .cornerRadius(8) + } + .frame(maxHeight: 300) + + Button { + UIPasteboard.general.string = backupJSON + copied = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + copied = false + } + } label: { + HStack { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + Text(copied ? "Copied!" : "Copy to Clipboard") + } + .frame(maxWidth: .infinity) + .padding() + .background(Color.brandAccent) + .foregroundColor(.white) + .cornerRadius(12) + } + + BodySText("Save this backup in a secure location. It contains your session secrets and keys.") + .foregroundColor(.textSecondary) + .multilineTextAlignment(.center) + } + .padding(24) + .background(Color.gray4) + } +} + +// MARK: - Import Backup Sheet + +struct ImportBackupSheet: View { + @Binding var importText: String + let onImport: (_ overwriteDeviceId: Bool) -> Void + let onDismiss: () -> Void + + @State private var overwriteDeviceId = false + + var body: some View { + VStack(spacing: 24) { + HStack { + TitleText("Import Backup", textColor: .white) + + Spacer() + + Button(action: onDismiss) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.textSecondary) + .font(.title2) + } + } + + TextEditor(text: $importText) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.white) + .frame(maxHeight: 200) + .padding(12) + .background(Color.gray6) + .cornerRadius(8) + + Toggle(isOn: $overwriteDeviceId) { + VStack(alignment: .leading, spacing: 4) { + BodyMText("Restore Device ID") + .foregroundColor(.white) + + BodySText("Use the device ID from the backup") + .foregroundColor(.textSecondary) + } + } + .toggleStyle(SwitchToggleStyle(tint: .brandAccent)) + + Button { + onImport(overwriteDeviceId) + } label: { + Text("Import Backup") + .frame(maxWidth: .infinity) + .padding() + .background(importText.isEmpty ? Color.gray : Color.brandAccent) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(importText.isEmpty) + + BodySText("Paste your backup JSON to restore sessions and keys.") + .foregroundColor(.textSecondary) + .multilineTextAlignment(.center) + } + .padding(24) + .background(Color.gray4) + } +} + +// MARK: - ViewModel + +class SessionManagementViewModel: ObservableObject { + @Published var sessions: [PubkySession] = [] + @Published var deviceId: String = "" + @Published var currentEpoch: UInt64 = 0 + @Published var cachedKeyCount: Int = 0 + + private let pubkyRingBridge = PubkyRingBridge.shared + + func loadSessions() { + sessions = pubkyRingBridge.getAllSessions() + deviceId = pubkyRingBridge.deviceId + // Epoch would typically come from a service, simplified for now + currentEpoch = UInt64(Date().timeIntervalSince1970 / 86400) // Daily epochs + cachedKeyCount = pubkyRingBridge.getCachedKeypairCount() + } + + func removeSession(pubkey: String) { + pubkyRingBridge.clearSession(pubkey: pubkey) + loadSessions() + } + + func clearAllSessions() { + pubkyRingBridge.clearAllSessions() + loadSessions() + } + + func exportBackupJSON() -> String { + do { + let data = try pubkyRingBridge.exportBackupAsJSON() + return String(data: data, encoding: .utf8) ?? "{}" + } catch { + return "{\"error\": \"Failed to export backup\"}" + } + } + + func importBackup(jsonString: String, overwriteDeviceId: Bool) { + guard let data = jsonString.data(using: .utf8) else { return } + do { + try pubkyRingBridge.importBackup(from: data, overwriteDeviceId: overwriteDeviceId) + loadSessions() + } catch { + Logger.error("Failed to import backup: \(error)", context: "SessionManagementViewModel") + } + } +} + +// MARK: - Preview + +#Preview { + SessionManagementView() + .preferredColorScheme(.dark) +} + diff --git a/Bitkit/Views/Settings/ChannelOrders.swift b/Bitkit/Views/Settings/ChannelOrders.swift index da6fc19a..fee930f2 100644 --- a/Bitkit/Views/Settings/ChannelOrders.swift +++ b/Bitkit/Views/Settings/ChannelOrders.swift @@ -133,7 +133,7 @@ struct CJitRow: View { } } -struct DetailRow: View { +private struct ChannelDetailRow: View { let label: String let value: String var isError: Bool = false @@ -179,49 +179,49 @@ struct OrderDetailView: View { var body: some View { List { Section("Order Details") { - DetailRow(label: "ID", value: order.id) - DetailRow(label: "Onchain txs", value: "\(order.payment?.onchain?.transactions.count ?? 0)") - DetailRow(label: "State", value: String(describing: order.state)) - DetailRow(label: "State 2", value: String(describing: order.state2)) - DetailRow(label: "LSP Balance", value: "\(order.lspBalanceSat) sats") - DetailRow(label: "Client Balance", value: "\(order.clientBalanceSat) sats") - DetailRow(label: "Total Fee", value: "\(order.feeSat) sats") - DetailRow(label: "Network Fee", value: "\(order.networkFeeSat) sats") - DetailRow(label: "Service Fee", value: "\(order.serviceFeeSat) sats") + ChannelDetailRow(label: "ID", value: order.id) + ChannelDetailRow(label: "Onchain txs", value: "\(order.payment?.onchain?.transactions.count ?? 0)") + ChannelDetailRow(label: "State", value: String(describing: order.state)) + ChannelDetailRow(label: "State 2", value: String(describing: order.state2)) + ChannelDetailRow(label: "LSP Balance", value: "\(order.lspBalanceSat) sats") + ChannelDetailRow(label: "Client Balance", value: "\(order.clientBalanceSat) sats") + ChannelDetailRow(label: "Total Fee", value: "\(order.feeSat) sats") + ChannelDetailRow(label: "Network Fee", value: "\(order.networkFeeSat) sats") + ChannelDetailRow(label: "Service Fee", value: "\(order.serviceFeeSat) sats") } Section("Channel Settings") { - DetailRow(label: "Zero Conf", value: order.zeroConf ? "Yes" : "No") - DetailRow(label: "Zero Reserve", value: order.zeroReserve ? "Yes" : "No") + ChannelDetailRow(label: "Zero Conf", value: order.zeroConf ? "Yes" : "No") + ChannelDetailRow(label: "Zero Reserve", value: order.zeroReserve ? "Yes" : "No") if let clientNodeId = order.clientNodeId { - DetailRow(label: "Client Node ID", value: clientNodeId) + ChannelDetailRow(label: "Client Node ID", value: clientNodeId) } - DetailRow(label: "Expiry Weeks", value: "\(order.channelExpiryWeeks)") - DetailRow(label: "Channel Expires", value: order.channelExpiresAt) - DetailRow(label: "Order Expires", value: order.orderExpiresAt) + ChannelDetailRow(label: "Expiry Weeks", value: "\(order.channelExpiryWeeks)") + ChannelDetailRow(label: "Channel Expires", value: order.channelExpiresAt) + ChannelDetailRow(label: "Order Expires", value: order.orderExpiresAt) } Section("LSP Information") { - DetailRow(label: "Alias", value: order.lspNode?.alias ?? "") - DetailRow(label: "Node ID", value: order.lspNode?.pubkey ?? "") + ChannelDetailRow(label: "Alias", value: order.lspNode?.alias ?? "") + ChannelDetailRow(label: "Node ID", value: order.lspNode?.pubkey ?? "") if let lnurl = order.lnurl { - DetailRow(label: "LNURL", value: lnurl) + ChannelDetailRow(label: "LNURL", value: lnurl) } } if let couponCode = order.couponCode { Section("Discount") { - DetailRow(label: "Coupon Code", value: couponCode) + ChannelDetailRow(label: "Coupon Code", value: couponCode) if let discount = order.discount { - DetailRow(label: "Discount Type", value: String(describing: discount.code)) - DetailRow(label: "Value", value: "\(discount.absoluteSat)") + ChannelDetailRow(label: "Discount Type", value: String(describing: discount.code)) + ChannelDetailRow(label: "Value", value: "\(discount.absoluteSat)") } } } Section("Timestamps") { - DetailRow(label: "Created", value: order.createdAt) - DetailRow(label: "Updated", value: order.updatedAt) + ChannelDetailRow(label: "Created", value: order.createdAt) + ChannelDetailRow(label: "Updated", value: order.updatedAt) } if order.state2 == .paid { @@ -250,44 +250,44 @@ struct CJitDetailView: View { var body: some View { List { Section("Entry Details") { - DetailRow(label: "ID", value: entry.id) - DetailRow(label: "State", value: String(describing: entry.state)) - DetailRow(label: "Channel Size", value: "\(entry.channelSizeSat) sats") + ChannelDetailRow(label: "ID", value: entry.id) + ChannelDetailRow(label: "State", value: String(describing: entry.state)) + ChannelDetailRow(label: "Channel Size", value: "\(entry.channelSizeSat) sats") if let error = entry.channelOpenError { - DetailRow(label: "Error", value: error, isError: true) + ChannelDetailRow(label: "Error", value: error, isError: true) } } Section("Fees") { - DetailRow(label: "Total Fee", value: "\(entry.feeSat) sats") - DetailRow(label: "Network Fee", value: "\(entry.networkFeeSat) sats") - DetailRow(label: "Service Fee", value: "\(entry.serviceFeeSat) sats") + ChannelDetailRow(label: "Total Fee", value: "\(entry.feeSat) sats") + ChannelDetailRow(label: "Network Fee", value: "\(entry.networkFeeSat) sats") + ChannelDetailRow(label: "Service Fee", value: "\(entry.serviceFeeSat) sats") } Section("Channel Settings") { - DetailRow(label: "Node ID", value: entry.nodeId) - DetailRow(label: "Expiry Weeks", value: "\(entry.channelExpiryWeeks)") + ChannelDetailRow(label: "Node ID", value: entry.nodeId) + ChannelDetailRow(label: "Expiry Weeks", value: "\(entry.channelExpiryWeeks)") } Section("LSP Information") { - DetailRow(label: "Alias", value: entry.lspNode.alias) - DetailRow(label: "Node ID", value: entry.lspNode.pubkey) + ChannelDetailRow(label: "Alias", value: entry.lspNode.alias) + ChannelDetailRow(label: "Node ID", value: entry.lspNode.pubkey) } if !entry.couponCode.isEmpty { Section("Discount") { - DetailRow(label: "Coupon Code", value: entry.couponCode) + ChannelDetailRow(label: "Coupon Code", value: entry.couponCode) if let discount = entry.discount { - DetailRow(label: "Discount Type", value: String(describing: discount.code)) - DetailRow(label: "Value", value: "\(discount.absoluteSat)") + ChannelDetailRow(label: "Discount Type", value: String(describing: discount.code)) + ChannelDetailRow(label: "Value", value: "\(discount.absoluteSat)") } } } Section("Timestamps") { - DetailRow(label: "Created", value: entry.createdAt) - DetailRow(label: "Updated", value: entry.updatedAt) - DetailRow(label: "Expires", value: entry.expiresAt) + ChannelDetailRow(label: "Created", value: entry.createdAt) + ChannelDetailRow(label: "Updated", value: entry.updatedAt) + ChannelDetailRow(label: "Expires", value: entry.expiresAt) } } .navigationTitle("cJIT Entry Details")