From 54df79c718cbb14b6a12e4a25f942381c4b383e5 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:53:50 -0500 Subject: [PATCH 01/92] add NSE target and add keychain capability to NSE and Quiet --- .../ios/NotificationAppExtension/Info.plist | 13 ++ .../NotificationAppExtension.entitlements | 10 + .../NotificationService.swift | 35 ++++ .../ios/Quiet.xcodeproj/project.pbxproj | 187 +++++++++++++++++- packages/mobile/ios/Quiet/Quiet.entitlements | 4 + .../mobile/ios/Quiet/QuietDebug.entitlements | 4 + 6 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/mobile/ios/NotificationAppExtension/Info.plist create mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements create mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationService.swift diff --git a/packages/mobile/ios/NotificationAppExtension/Info.plist b/packages/mobile/ios/NotificationAppExtension/Info.plist new file mode 100644 index 0000000000..57421ebf9b --- /dev/null +++ b/packages/mobile/ios/NotificationAppExtension/Info.plist @@ -0,0 +1,13 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements new file mode 100644 index 0000000000..7ac57ebda7 --- /dev/null +++ b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements @@ -0,0 +1,10 @@ + + + + + keychain-access-groups + + $(AppIdentifierPrefix)com.quietmobile + + + diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift new file mode 100644 index 0000000000..0f337992d4 --- /dev/null +++ b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift @@ -0,0 +1,35 @@ +// +// NotificationService.swift +// NotificationAppExtension +// +// Created by Isla Koenigsknecht on 2/18/26. +// + +import UserNotifications + +class NotificationService: UNNotificationServiceExtension { + + var contentHandler: ((UNNotificationContent) -> Void)? + var bestAttemptContent: UNMutableNotificationContent? + + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + self.contentHandler = contentHandler + bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) + + if let bestAttemptContent = bestAttemptContent { + // Modify the notification content here... + bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" + + contentHandler(bestAttemptContent) + } + } + + override func serviceExtensionTimeWillExpire() { + // Called just before the extension will be terminated by the system. + // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. + if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { + contentHandler(bestAttemptContent) + } + } + +} diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 79efc9d819..9f813eb6a8 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -56,6 +56,7 @@ 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; }; + 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; }; @@ -69,6 +70,13 @@ remoteGlobalIDString = 13B07F861A680F5B00A75B9A; remoteInfo = QuietMobile; }; + 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 665E5BBC2F46402C005D2086; + remoteInfo = NotificationAppExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -84,6 +92,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -621,12 +640,27 @@ 18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; }; 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; + 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 665E5BBE2F46402C005D2086 /* NotificationAppExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationAppExtension; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 00E356EB1AD99517003FC87E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -647,6 +681,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 665E5BBA2F46402C005D2086 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -4755,6 +4796,7 @@ 13B07FAE1A68108700A75B9A /* Quiet */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* QuietTests */, + 665E5BBE2F46402C005D2086 /* NotificationAppExtension */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 624523FCC5994B7E9869E9CF /* Resources */, @@ -4770,6 +4812,7 @@ children = ( 13B07F961A680F5B00A75B9A /* Quiet.app */, 00E356EE1AD99517003FC87E /* QuietTests.xctest */, + 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */, ); name = Products; sourceTree = ""; @@ -4816,16 +4859,40 @@ 1868C095292F8FE2001D6D5E /* Embed Frameworks */, 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */, A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */, + 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + 665E5BC32F46402C005D2086 /* PBXTargetDependency */, ); name = Quiet; productName = QuietMobile; productReference = 13B07F961A680F5B00A75B9A /* Quiet.app */; productType = "com.apple.product-type.application"; }; + 665E5BBC2F46402C005D2086 /* NotificationAppExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */; + buildPhases = ( + 665E5BB92F46402C005D2086 /* Sources */, + 665E5BBA2F46402C005D2086 /* Frameworks */, + 665E5BBB2F46402C005D2086 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 665E5BBE2F46402C005D2086 /* NotificationAppExtension */, + ); + name = NotificationAppExtension; + packageProductDependencies = ( + ); + productName = NotificationAppExtension; + productReference = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -4833,6 +4900,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 2610; TargetAttributes = { 00E356ED1AD99517003FC87E = { @@ -4842,6 +4910,9 @@ 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1250; }; + 665E5BBC2F46402C005D2086 = { + CreatedOnToolsVersion = 16.2; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */; @@ -4859,6 +4930,7 @@ targets = ( 13B07F861A680F5B00A75B9A /* Quiet */, 00E356ED1AD99517003FC87E /* QuietTests */, + 665E5BBC2F46402C005D2086 /* NotificationAppExtension */, ); }; /* End PBXProject section */ @@ -4909,6 +4981,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 665E5BBB2F46402C005D2086 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -5182,6 +5261,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 665E5BB92F46402C005D2086 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -5190,6 +5276,11 @@ target = 13B07F861A680F5B00A75B9A /* Quiet */; targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; }; + 665E5BC32F46402C005D2086 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */; + targetProxy = 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -5453,6 +5544,91 @@ }; name = Release; }; + 665E5BC62F46402D005D2086 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = CTYKSWN9T4; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationAppExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 665E5BC72F46402D005D2086 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = CTYKSWN9T4; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = NotificationAppExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -5622,6 +5798,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 665E5BC62F46402D005D2086 /* Debug */, + 665E5BC72F46402D005D2086 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/mobile/ios/Quiet/Quiet.entitlements b/packages/mobile/ios/Quiet/Quiet.entitlements index 903def2af5..599333ab05 100644 --- a/packages/mobile/ios/Quiet/Quiet.entitlements +++ b/packages/mobile/ios/Quiet/Quiet.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)com.quietmobile + diff --git a/packages/mobile/ios/Quiet/QuietDebug.entitlements b/packages/mobile/ios/Quiet/QuietDebug.entitlements index 903def2af5..599333ab05 100644 --- a/packages/mobile/ios/Quiet/QuietDebug.entitlements +++ b/packages/mobile/ios/Quiet/QuietDebug.entitlements @@ -4,5 +4,9 @@ aps-environment development + keychain-access-groups + + $(AppIdentifierPrefix)com.quietmobile + From 97f1731b7688f83b88be3cba9bc2c946f72ed366 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 25 Feb 2026 11:37:56 -0500 Subject: [PATCH 02/92] Pass keys from backend to state manager and write to keychain --- .../auth/services/crypto/crypto.service.ts | 19 +- .../backend/src/nest/auth/sigchain.service.ts | 67 ++++++- packages/mobile/ios/CommunicationModule.swift | 15 ++ packages/mobile/ios/KeychainHandler.swift | 188 ++++++++++++++++++ .../ios/Quiet.xcodeproj/project.pbxproj | 4 + .../mobile/src/store/keys/keys.master.saga.ts | 20 ++ .../mobile/src/store/keys/keys.selectors..ts | 15 ++ packages/mobile/src/store/keys/keys.slice.ts | 28 +++ .../mobile/src/store/keys/keys.transform.ts | 14 ++ packages/mobile/src/store/keys/keys.type.ts | 11 + .../saveKeysInKeychain.saga.ts | 64 ++++++ packages/mobile/src/store/root.reducer.ts | 2 + packages/mobile/src/store/root.saga.ts | 2 + packages/mobile/src/store/store.keys.ts | 1 + packages/types/src/index.ts | 1 + packages/types/src/keys.ts | 12 ++ packages/types/src/socket.ts | 3 + 17 files changed, 457 insertions(+), 9 deletions(-) create mode 100644 packages/mobile/ios/KeychainHandler.swift create mode 100644 packages/mobile/src/store/keys/keys.master.saga.ts create mode 100644 packages/mobile/src/store/keys/keys.selectors..ts create mode 100644 packages/mobile/src/store/keys/keys.slice.ts create mode 100644 packages/mobile/src/store/keys/keys.transform.ts create mode 100644 packages/mobile/src/store/keys/keys.type.ts create mode 100644 packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts create mode 100644 packages/types/src/keys.ts diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts index 63af9863da..495aeb61ef 100644 --- a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -14,9 +14,10 @@ import { import { ChainServiceBase } from '../chainServiceBase' import { SigChain } from '../../sigchain' import { asymmetric, Keyset, Member, SignedEnvelope, EncryptStreamTeamPayload } from '@localfirst/auth' +import { KeyMap } from '@localfirst/auth/team/selectors' import { DEFAULT_SEARCH_OPTIONS, MemberSearchOptions } from '../members/types' import { createLogger } from '../../../common/logger' -import { KeyMetadata } from '3rd-party/auth/packages/crdx/dist' +import { KeyMetadata } from '@localfirst/crdx' const logger = createLogger('auth:cryptoService') @@ -36,6 +37,22 @@ class CryptoService extends ChainServiceBase { }) } + public getPublicKeysForAllMembers(includeSelf: boolean = false): Keyset[] { + const members = this.sigChain.users.getAllUsers() + const keysByMember = [] + for (const member of members) { + if (member.userId === this.sigChain.context.user.userId && !includeSelf) { + continue + } + keysByMember.push(member.keys) + } + return keysByMember + } + + public getAllKeys(): KeyMap { + return this.sigChain.team!.allKeys() + } + public sign(message: any): SignedEnvelope { return this.sigChain.team!.sign(message) } diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index dd417ad981..d197aa98c6 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -1,6 +1,17 @@ -import { Inject, Injectable, OnModuleInit } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' import { SigChain } from './sigchain' -import { Connection, InviteeMemberContext, Keyring, LocalUserContext, MemberContext, Team } from '@localfirst/auth' +import { + Connection, + Hash, + InviteeMemberContext, + Keyring, + LocalUserContext, + MemberContext, + Team, + UserWithSecrets, + DeviceWithSecrets, +} from '@localfirst/auth' +import { KeyMetadata } from '@localfirst/crdx' import { LocalDbService } from '../local-db/local-db.service' import { createLogger } from '../common/logger' import { SocketService } from '../socket/socket.service' @@ -10,12 +21,11 @@ import { type DeviceService } from './services/members/device.service' import { type InviteService } from './services/invites/invite.service' import { type UserService } from './services/members/user.service' import { type CryptoService } from './services/crypto/crypto.service' -import { type UserWithSecrets } from '@localfirst/auth' -import { type DeviceWithSecrets } from '@localfirst/auth' import { SERVER_IO_PROVIDER } from '../const' import { ServerIoProviderTypes } from '../types' import EventEmitter from 'events' import { GetChainFilter } from './types' +import { KeysUpdatedEvent, KeyWithMetadata } from 'packages/types/src/keys' @Injectable() export class SigChainService extends EventEmitter { @@ -23,6 +33,7 @@ export class SigChainService extends EventEmitter { private readonly logger = createLogger(SigChainService.name) private chains: Map = new Map() public connections: Map = new Map() + private lastUpdatedLink: Hash constructor( @Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes, @@ -133,6 +144,14 @@ export class SigChainService extends EventEmitter { } private handleChainUpdate = () => { + this._updateUsersOnChainUpdate() + this._updateKeysOnChainUpdate() + this.emit('updated') + this.saveChain(this.activeChainTeamName!) + this.logger.info('Chain updated, emitted updated event') + } + + private _updateUsersOnChainUpdate() { const users = this.getActiveChain() .team?.members() .map(user => ({ @@ -141,10 +160,42 @@ export class SigChainService extends EventEmitter { isRegistered: true, isDuplicated: false, })) as User[] - this.socketService.emit(SocketEvents.USERS_UPDATED, { users }) - this.emit('updated') - this.saveChain(this.activeChainTeamName!) - this.logger.info('Chain updated, emitted updated event') + this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users }) + } + + // TODO: only fetch keys that have been updated recently + private _updateKeysOnChainUpdate() { + const secretKeys: KeyWithMetadata[] = [] + const sigKeys: KeyWithMetadata[] = [] + const userPublicKeys: KeyWithMetadata[] = [] + const allKeys = this.getActiveChain().crypto.getAllKeys() + for (const keyData of Object.values(allKeys)) { + for (const keyTypeData of Object.values(keyData)) { + for (const keyTypeGenData of Object.values(keyTypeData)) { + secretKeys.push({ + scope: { name: keyTypeGenData.name, type: keyTypeGenData.type, generation: keyTypeGenData.generation }, + key: keyTypeGenData.secretKey, + }) + } + } + } + // TODO: update to pull all generations of user public/sig keys + const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(false) + for (const keySet of allUserPublicKeys) { + userPublicKeys.push({ + scope: { name: keySet.name, type: keySet.type, generation: keySet.generation }, + key: keySet.encryption, + }) + sigKeys.push({ + scope: { name: keySet.name, type: keySet.type, generation: keySet.generation }, + key: keySet.signature, + }) + } + this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, { + secretKeys, + sigKeys, + userPublicKeys, + } as KeysUpdatedEvent) } private attachSocketListeners(chain: SigChain): void { diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index f6961cdbfe..b579d2e964 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -12,6 +12,9 @@ class CommunicationModule: RCTEventEmitter { static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_" + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule") + + let keychainHandler = KeychainHandler() @objc func sendDataPort(port: UInt16, socketIOSecret: String) { @@ -56,6 +59,18 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func saveKeysInKeychain(newKeys: KeyWithScope[]) { + CommunicationModule.logger.debug("Saving \(newKeys.count) keys in keychain") + for key in newKeys { + do { + self.keychainHandler.addLfaKey(scope: key.scope, key: key.key) + } catch { + CommunicationModule.logger.error("Error while saving key in keychain", error) + } + } + } + @objc func checkNotificationPermission() { UNUserNotificationCenter.current().getNotificationSettings { settings in diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift new file mode 100644 index 0000000000..49a928e70c --- /dev/null +++ b/packages/mobile/ios/KeychainHandler.swift @@ -0,0 +1,188 @@ +//import CryptoKit +//import Security +//import CoreData + +public enum KeychainError: Error { + case noPassword + case unexpectedPasswordData + case unexpectedItemData + case unhandledError(status: OSStatus) +} + +public enum ConversionError: Error { + case stringToBytesError +} + +public enum KeychainHandlerError: Error { + case noKeyFound + case malformedKey + case unhandledError(reason: Any) +} + +public struct KeyScope { + var name: String + var generation: Int + var type: String + var keyType: String +} + +public enum KeyAddStatus { + case success + case duplicateScope +} + +public struct KeyWithScope { + var scope: KeyScope + var key: String +} + +@objc(KeychainHandler) +class KeychainHandler: NSObject { + private let masterKeyName: String = "quiet_master_key" + private let keychainGroupName: String = "com.quietmobile" + + public func getMasterKey() throws -> SymmetricKey { + do { + let password: String = try _getKeyImpl(keyName: masterKeyName) + let passwordBytes: ContiguousBytes = try _stringToBytes(str: password) + return SymmetricKey(data: passwordBytes) + } catch KeychainError.noPassword { + throw KeychainHandlerError.noKeyFound + } catch KeychainError.unexpectedPasswordData { + throw KeychainHandlerError.malformedKey + } catch ConversionError.stringToBytesError { + throw KeychainHandlerError.malformedKey + } catch { + throw KeychainHandlerError.unhandledError(reason: error) + } + } + + public func getLfaKeyString(scope: KeyScope) throws -> String { + do { + let keyName: String = _keyScopeToKeyName(scope: scope) + let password: String = try _getKeyImpl(keyName: keyName) + return password + } catch KeychainError.noPassword { + throw KeychainHandlerError.noKeyFound + } catch KeychainError.unexpectedPasswordData { + throw KeychainHandlerError.malformedKey + } catch ConversionError.stringToBytesError { + throw KeychainHandlerError.malformedKey + } catch { + throw KeychainHandlerError.unhandledError(reason: error) + } + } + + public func createMasterKey() throws -> SymmetricKey { + var existingKey: SymmetricKey? + do { + existingKey = try getMasterKey() + } catch KeychainHandlerError.noKeyFound { + existingKey = nil + } catch { + throw error + } + + guard existingKey == nil else { return existingKey! } + do { + let newKey: SymmetricKey = _generateAESKey() + let keyData: Data = _symmetricKeyToData(key: newKey) + let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: masterKeyName, keyData: keyData) + guard addStatus == KeyAddStatus.success else { throw KeychainHandlerError.unhandledError(reason: addStatus) } + return newKey + } catch { + throw KeychainHandlerError.unhandledError(reason: error) + } + } + + public func addLfaKey(scope: KeyScope, key: String) throws -> KeyAddStatus { + var existingKey: String? + do { + existingKey = try getLfaKeyString(scope: scope) + } catch KeychainHandlerError.noKeyFound { + existingKey = nil + } catch KeychainHandlerError.malformedKey { + existingKey = nil + } catch { + throw error + } + + guard existingKey == nil else { + guard existingKey == key else { return KeyAddStatus.duplicateScope } + return KeyAddStatus.success + } + + do { + let keyName: String = _keyScopeToKeyName(scope: scope) + let keyData: Data = try _stringToBytes(str: key) + let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData) + return addStatus + } catch { + throw KeychainHandlerError.unhandledError(reason: error) + } + } + + private func _getKeyImpl(keyName: String) throws -> String { + var existingKey: CFTypeRef? + let query: [String: Any] = [ + kSecClass as String: kSecSharedPassword, + kSecAttrService as String: keychainGroupName, + kSecAttrAccount as String: keyName, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnAttributes as String: true, + kSecReturnData as String: true + ] + let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey) + guard status != errSecItemNotFound else { throw KeychainError.noPassword } + guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } + guard let existingItem: [String : Any] = existingKey as? [String : Any], + let passwordData = existingItem[kSecValueData as String] as? Data, + let password = String(data: passwordData, encoding: String.Encoding.utf8), + let account = existingItem[kSecAttrAccount as String] as? String + else { + throw KeychainError.unexpectedPasswordData + } + return password + } + + private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: keyName, + kSecAttrService as String: keychainGroupName, + kSecValueData as String: keyData, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status: OSStatus = SecItemAdd(query as CFDictionary, nil) + if status == errSecSuccess { + return KeyAddStatus.success + } else if status == errSecDuplicateItem { + return KeyAddStatus.duplicateScope + } else { + throw KeychainError.unhandledError(status: status) + } + } + + private func _generateAESKey() -> SymmetricKey { + let key: SymmetricKey = SymmetricKey(size: .bits256) + return key + } + + private func _stringToBytes(str: String) throws -> Data { + let bytes: Data? = str.data(using: .utf8) + guard bytes != nil else { throw ConversionError.stringToBytesError } + return bytes! + } + + private func _symmetricKeyToData(key: SymmetricKey) -> Data { + let keyData: Data = key.withUnsafeBytes { body in + Data(body) + } + return keyData + } + + private func _keyScopeToKeyName(scope: KeyScope) -> String { + return "quiet_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)" + } +} diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 9f813eb6a8..3503c544f8 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; }; 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; }; @@ -641,6 +642,7 @@ 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; @@ -711,6 +713,7 @@ 13B07FAE1A68108700A75B9A /* Quiet */ = { isa = PBXGroup; children = ( + 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */, 180E120A2AEFB7F900804659 /* Utils.swift */, 18FD2A36296F009E00A2B8C0 /* AppDelegate.h */, 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */, @@ -5253,6 +5256,7 @@ 1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */, 1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */, 1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */, + 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */, 1868C43A2930D859001D6D5E /* FindFreePort.swift in Sources */, 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */, 1868BCED292E9212001D6D5E /* NodeRunner.mm in Sources */, diff --git a/packages/mobile/src/store/keys/keys.master.saga.ts b/packages/mobile/src/store/keys/keys.master.saga.ts new file mode 100644 index 0000000000..a35dff4a2a --- /dev/null +++ b/packages/mobile/src/store/keys/keys.master.saga.ts @@ -0,0 +1,20 @@ +import { takeEvery, cancelled } from 'redux-saga/effects' +import { all } from 'typed-redux-saga' +import { type Socket } from '@quiet/state-manager/src/types' +import { keysActions } from './keys.slice' +import { saveKeysInKeychainSaga } from './saveKeysInKeychain/saveKeysInKeychain.saga' +import { createLogger } from '../../utils/logger' + +const logger = createLogger('keysMasterSaga') + +export function* keysMasterSaga(): Generator { + logger.info('keysMasterSaga starting') + try { + yield all([takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga)]) + } finally { + logger.info('keysMasterSaga stopping') + if (yield cancelled()) { + logger.info('keysMasterSaga cancelled') + } + } +} diff --git a/packages/mobile/src/store/keys/keys.selectors..ts b/packages/mobile/src/store/keys/keys.selectors..ts new file mode 100644 index 0000000000..e6016ba4c7 --- /dev/null +++ b/packages/mobile/src/store/keys/keys.selectors..ts @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect' +import { StoreKeys } from '../store.keys' +import { CreatedSelectors, StoreState } from '../store.types' + +const keysSlice: CreatedSelectors[StoreKeys.Keys] = (state: StoreState) => state[StoreKeys.Keys] + +export const allKeys = createSelector(keysSlice, state => ({ + secretKeys: state.secretKeys, + userPublicKeys: state.userPublicKeys, + sigKeys: state.sigKeys, +})) + +export const keysSelectors = { + allKeys, +} diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts new file mode 100644 index 0000000000..c69ef0ec9b --- /dev/null +++ b/packages/mobile/src/store/keys/keys.slice.ts @@ -0,0 +1,28 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { StoreKeys } from '../store.keys' +import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types' +import { createLogger } from '../../utils/logger' + +const logger = createLogger('keysSlice') + +export class KeysState { + public secretKeys: KeyWithMetadata[] = [] + public userPublicKeys: KeyWithMetadata[] = [] + public sigKeys: KeyWithMetadata[] = [] +} + +export const keysSlice = createSlice({ + initialState: { ...new KeysState() }, + name: StoreKeys.Keys, + reducers: { + setKeys: (state, action: PayloadAction) => { + state.secretKeys = action.payload.secretKeys + state.sigKeys = action.payload.sigKeys + state.userPublicKeys = action.payload.userPublicKeys + }, + saveKeysInKeychain: (state, _action: PayloadAction) => state, + }, +}) + +export const keysActions = keysSlice.actions +export const keysReducer = keysSlice.reducer diff --git a/packages/mobile/src/store/keys/keys.transform.ts b/packages/mobile/src/store/keys/keys.transform.ts new file mode 100644 index 0000000000..797b644871 --- /dev/null +++ b/packages/mobile/src/store/keys/keys.transform.ts @@ -0,0 +1,14 @@ +import { createTransform } from 'redux-persist' +import { StoreKeys } from '../store.keys' +import { KeysState } from './keys.slice' + +export const KeysTransform = createTransform( + (inboundState: KeysState, _key: any) => { + return inboundState + }, + (outboundState: KeysState, _key: any) => { + // TODO: determine if we still need this transform + return outboundState + }, + { whitelist: [StoreKeys.Keys] } +) diff --git a/packages/mobile/src/store/keys/keys.type.ts b/packages/mobile/src/store/keys/keys.type.ts new file mode 100644 index 0000000000..b0e994fcfa --- /dev/null +++ b/packages/mobile/src/store/keys/keys.type.ts @@ -0,0 +1,11 @@ +export type ExtendedKeyScope = { + type: string + name: string + generation: number + keyType: string +} + +export interface StorableKey { + scope: ExtendedKeyScope + key: string +} diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts new file mode 100644 index 0000000000..b669b966aa --- /dev/null +++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts @@ -0,0 +1,64 @@ +import { type PayloadAction } from '@reduxjs/toolkit' +import { call, select, put } from 'typed-redux-saga' +import { KeysUpdatedEvent } from '@quiet/types' +import { createLogger } from '../../../utils/logger' +import { keysActions } from '../keys.slice' +import { keysSelectors } from '../keys.selectors.' + +import _ from 'lodash' +import { NativeModules } from 'react-native' +import { StorableKey } from '../keys.type' + +const logger = createLogger('saveKeysInKeychainSaga') + +export function* saveKeysInKeychainSaga(action: PayloadAction): Generator { + logger.debug('Storing keys in ios keychain') + const existingKeys = yield* select(keysSelectors.allKeys) + const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key') + const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key') + const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key') + logger.debug('Updating keys state') + yield* put(keysActions.setKeys(action.payload)) + + const newKeysPayload: KeysUpdatedEvent = { + secretKeys: newSecretKeys, + userPublicKeys: newUserPublicKeys, + sigKeys: newSigKeys, + } + const keysToSave: StorableKey[] = newSecretKeys.map( + keyWithMetadata => + ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'secret', + }, + key: keyWithMetadata.key, + } as StorableKey) + ) + keysToSave.push( + ...newUserPublicKeys.map( + keyWithMetadata => + ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'userPublic', + }, + key: keyWithMetadata.key, + } as StorableKey) + ) + ) + keysToSave.push( + ...newSigKeys.map( + keyWithMetadata => + ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'userSig', + }, + key: keyWithMetadata.key, + } as StorableKey) + ) + ) + logger.debug('Putting new keys in keychain', keysToSave) + yield* call(NativeModules.CommunicationModule.saveKeysInKeychain, newKeysPayload) +} diff --git a/packages/mobile/src/store/root.reducer.ts b/packages/mobile/src/store/root.reducer.ts index f5570fc579..0ffbbbb304 100644 --- a/packages/mobile/src/store/root.reducer.ts +++ b/packages/mobile/src/store/root.reducer.ts @@ -5,6 +5,7 @@ import { initReducer } from './init/init.slice' import { navigationReducer } from './navigation/navigation.slice' import { nativeServicesReducer, nativeServicesActions } from './nativeServices/nativeServices.slice' import { pushNotificationsReducer } from './pushNotifications/pushNotifications.slice' +import { keysReducer } from './keys/keys.slice' export const reducers = { ...stateManagerReducers.reducers, @@ -12,6 +13,7 @@ export const reducers = { [StoreKeys.Navigation]: navigationReducer, [StoreKeys.NativeServices]: nativeServicesReducer, [StoreKeys.PushNotifications]: pushNotificationsReducer, + [StoreKeys.Keys]: keysReducer, } export const allReducers = combineReducers(reducers) diff --git a/packages/mobile/src/store/root.saga.ts b/packages/mobile/src/store/root.saga.ts index c17191aa3d..98a149f6a6 100644 --- a/packages/mobile/src/store/root.saga.ts +++ b/packages/mobile/src/store/root.saga.ts @@ -9,6 +9,7 @@ import { clearReduxStore } from './nativeServices/leaveCommunity/leaveCommunity. import { pushNotificationsMasterSaga } from './pushNotifications/pushNotifications.master.saga' import { setEngine, CryptoEngine } from 'pkijs' import { createLogger } from '../utils/logger' +import { keysMasterSaga } from './keys/keys.master.saga' const logger = createLogger('root') @@ -54,6 +55,7 @@ function* storeReadySaga(): Generator { fork(navigationMasterSaga), fork(nativeServicesMasterSaga), fork(pushNotificationsMasterSaga), + fork(keysMasterSaga), // Below line is reponsible for displaying notifications about messages from channels other than currently viewing one takeEvery(publicChannels.actions.markUnreadChannel.type, showNotificationSaga), takeLeading(initActions.canceledRootTask.type, clearReduxStore), diff --git a/packages/mobile/src/store/store.keys.ts b/packages/mobile/src/store/store.keys.ts index fdb67eed97..9389713392 100644 --- a/packages/mobile/src/store/store.keys.ts +++ b/packages/mobile/src/store/store.keys.ts @@ -3,4 +3,5 @@ export enum StoreKeys { Navigation = 'Navigation', NativeServices = 'NativeServices', PushNotifications = 'PushNotifications', + Keys = 'Keys', } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 2d92a64467..a795c26e15 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -13,3 +13,4 @@ export * from './network' export * from './test' export * from './captcha' export * from './serializer' +export * from './keys' diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts new file mode 100644 index 0000000000..95e24fe0e2 --- /dev/null +++ b/packages/types/src/keys.ts @@ -0,0 +1,12 @@ +import { Base58, KeyMetadata } from '@localfirst/crdx' + +export interface KeyWithMetadata { + scope: KeyMetadata + key: string | Base58 +} + +export interface KeysUpdatedEvent { + secretKeys: KeyWithMetadata[] + userPublicKeys: KeyWithMetadata[] + sigKeys: KeyWithMetadata[] +} diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index d6ac8999f0..e1a7e0ec6a 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -39,6 +39,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' +import { KeysUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- // SocketActions: These are the actions the frontend emits to the backend @@ -128,6 +129,7 @@ export enum SocketEvents { USERS_UPDATED = 'usersUpdated', USERS_REMOVED = 'usersRemoved', USER_PROFILES_STORED = 'userProfilesStored', + KEYS_UPDATED = 'keysUpdated', // ====== Files ====== FILE_ATTACHED = 'fileUploaded', @@ -229,6 +231,7 @@ export interface SocketEventsMap { [SocketEvents.USERS_UPDATED]: EmitEvent [SocketEvents.USERS_REMOVED]: EmitEvent [SocketEvents.USER_PROFILES_STORED]: EmitEvent + [SocketEvents.KEYS_UPDATED]: EmitEvent // ====== Files ====== [SocketEvents.FILE_ATTACHED]: EmitEvent From 7700b4f2077b08ca2f7bfbdfe4a80a4906542bb4 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:02:23 -0500 Subject: [PATCH 03/92] Properly store keys in ios keychain --- .../backend/src/nest/auth/sigchain.service.ts | 20 +- packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 18 +- packages/mobile/ios/KeychainHandler.swift | 60 ++-- .../ios/NotificationAppExtension/Info.plist | 13 - .../NotificationAppExtension.entitlements | 10 - .../NotificationService.swift | 35 --- .../ios/Quiet.xcodeproj/project.pbxproj | 287 ++++-------------- .../startConnection/startConnection.saga.ts | 11 +- packages/mobile/src/store/keys/keys.type.ts | 1 + .../saveKeysInKeychain.saga.ts | 75 +++-- packages/types/src/keys.ts | 1 + 12 files changed, 162 insertions(+), 370 deletions(-) delete mode 100644 packages/mobile/ios/NotificationAppExtension/Info.plist delete mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements delete mode 100644 packages/mobile/ios/NotificationAppExtension/NotificationService.swift diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index d197aa98c6..1b8392425d 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -11,7 +11,7 @@ import { UserWithSecrets, DeviceWithSecrets, } from '@localfirst/auth' -import { KeyMetadata } from '@localfirst/crdx' +import { KeyMetadata, KeyType } from '@localfirst/crdx' import { LocalDbService } from '../local-db/local-db.service' import { createLogger } from '../common/logger' import { SocketService } from '../socket/socket.service' @@ -25,7 +25,9 @@ import { SERVER_IO_PROVIDER } from '../const' import { ServerIoProviderTypes } from '../types' import EventEmitter from 'events' import { GetChainFilter } from './types' -import { KeysUpdatedEvent, KeyWithMetadata } from 'packages/types/src/keys' +import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types' +import { EncryptionScopeType } from './services/crypto/types' +import * as os from 'os' @Injectable() export class SigChainService extends EventEmitter { @@ -165,6 +167,11 @@ export class SigChainService extends EventEmitter { // TODO: only fetch keys that have been updated recently private _updateKeysOnChainUpdate() { + if ((process.platform as string) !== 'ios') { + this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform) + return + } + const secretKeys: KeyWithMetadata[] = [] const sigKeys: KeyWithMetadata[] = [] const userPublicKeys: KeyWithMetadata[] = [] @@ -180,7 +187,7 @@ export class SigChainService extends EventEmitter { } } // TODO: update to pull all generations of user public/sig keys - const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(false) + const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true) for (const keySet of allUserPublicKeys) { userPublicKeys.push({ scope: { name: keySet.name, type: keySet.type, generation: keySet.generation }, @@ -191,11 +198,13 @@ export class SigChainService extends EventEmitter { key: keySet.signature, }) } - this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, { + const keyUpdateEvent: KeysUpdatedEvent = { secretKeys, sigKeys, userPublicKeys, - } as KeysUpdatedEvent) + teamId: this.activeChain.team!.id, + } + this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent) } private attachSocketListeners(chain: SigChain): void { @@ -259,6 +268,7 @@ export class SigChainService extends EventEmitter { const sigChain = SigChain.create(teamName, username) this.addChain(sigChain, setActive, teamName) await this.saveChain(teamName) + this.handleChainUpdate() return sigChain } diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 254cfa1975..5f0e6155ef 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -5,4 +5,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(handleIncomingEvents:(NSString *)event payload:(NSString *)payload extra:(NSString *)extra) RCT_EXTERN_METHOD(requestNotificationPermission) RCT_EXTERN_METHOD(checkNotificationPermission) +RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index b579d2e964..71cb912914 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -1,4 +1,5 @@ import UserNotifications +import OSLog @objc(CommunicationModule) class CommunicationModule: RCTEventEmitter { @@ -58,15 +59,20 @@ class CommunicationModule: RCTEventEmitter { } } } - + @objc - func saveKeysInKeychain(newKeys: KeyWithScope[]) { - CommunicationModule.logger.debug("Saving \(newKeys.count) keys in keychain") - for key in newKeys { + func saveKeysInKeychain(_ newKeys: NSArray) { + let decoder = JSONDecoder() + for keyAsAny in newKeys { do { - self.keychainHandler.addLfaKey(scope: key.scope, key: key.key) + let keyAsString: String = keyAsAny as! String + let data = Data(keyAsString.utf8) + let decodedKeyWithScope = try decoder.decode(KeyWithScope.self, from: data) + try self.keychainHandler.addLfaKey(keyWithScope: decodedKeyWithScope) + let stored = try self.keychainHandler.getLfaKeyString(teamId: decodedKeyWithScope.teamId, scope: decodedKeyWithScope.scope) + CommunicationModule.logger.info("Stored key matches? \(stored == decodedKeyWithScope.key) \(String(describing: decodedKeyWithScope.scope))") } catch { - CommunicationModule.logger.error("Error while saving key in keychain", error) + CommunicationModule.logger.error("Error while saving key in keychain: \(error)") } } } diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index 49a928e70c..cad13112bf 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -1,6 +1,15 @@ -//import CryptoKit -//import Security -//import CoreData +// +// KeychainError.swift +// Quiet +// +// Created by Isla Koenigsknecht on 2/25/26. +// + + +import CryptoKit +import Security +import CoreData +import OSLog public enum KeychainError: Error { case noPassword @@ -19,11 +28,11 @@ public enum KeychainHandlerError: Error { case unhandledError(reason: Any) } -public struct KeyScope { - var name: String - var generation: Int - var type: String - var keyType: String +public struct KeyScope: Codable { + let name: String + let generation: Int + let type: String + let keyType: String } public enum KeyAddStatus { @@ -31,15 +40,18 @@ public enum KeyAddStatus { case duplicateScope } -public struct KeyWithScope { - var scope: KeyScope - var key: String +public struct KeyWithScope: Codable { + let scope: KeyScope + let key: String + let teamId: String } @objc(KeychainHandler) class KeychainHandler: NSObject { private let masterKeyName: String = "quiet_master_key" private let keychainGroupName: String = "com.quietmobile" + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler") public func getMasterKey() throws -> SymmetricKey { do { @@ -57,9 +69,9 @@ class KeychainHandler: NSObject { } } - public func getLfaKeyString(scope: KeyScope) throws -> String { + public func getLfaKeyString(teamId: String, scope: KeyScope) throws -> String { do { - let keyName: String = _keyScopeToKeyName(scope: scope) + let keyName: String = _keyScopeToKeyName(teamId: teamId, scope: scope) let password: String = try _getKeyImpl(keyName: keyName) return password } catch KeychainError.noPassword { @@ -95,26 +107,27 @@ class KeychainHandler: NSObject { } } - public func addLfaKey(scope: KeyScope, key: String) throws -> KeyAddStatus { + public func addLfaKey(keyWithScope: KeyWithScope) throws -> KeyAddStatus { var existingKey: String? do { - existingKey = try getLfaKeyString(scope: scope) + existingKey = try getLfaKeyString(teamId: keyWithScope.teamId, scope: keyWithScope.scope) } catch KeychainHandlerError.noKeyFound { existingKey = nil } catch KeychainHandlerError.malformedKey { existingKey = nil } catch { + KeychainHandler.logger.error("Error while getting existing LFA key for scope \(String(describing: keyWithScope.scope)): \(error)") throw error } guard existingKey == nil else { - guard existingKey == key else { return KeyAddStatus.duplicateScope } - return KeyAddStatus.success + guard existingKey == keyWithScope.key else { return KeyAddStatus.duplicateScope } + return KeyAddStatus.success } do { - let keyName: String = _keyScopeToKeyName(scope: scope) - let keyData: Data = try _stringToBytes(str: key) + let keyName: String = _keyScopeToKeyName(teamId: keyWithScope.teamId, scope: keyWithScope.scope) + let keyData: Data = try _stringToBytes(str: keyWithScope.key) let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData) return addStatus } catch { @@ -125,7 +138,7 @@ class KeychainHandler: NSObject { private func _getKeyImpl(keyName: String) throws -> String { var existingKey: CFTypeRef? let query: [String: Any] = [ - kSecClass as String: kSecSharedPassword, + kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainGroupName, kSecAttrAccount as String: keyName, kSecMatchLimit as String: kSecMatchLimitOne, @@ -137,8 +150,7 @@ class KeychainHandler: NSObject { guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } guard let existingItem: [String : Any] = existingKey as? [String : Any], let passwordData = existingItem[kSecValueData as String] as? Data, - let password = String(data: passwordData, encoding: String.Encoding.utf8), - let account = existingItem[kSecAttrAccount as String] as? String + let password = String(data: passwordData, encoding: String.Encoding.utf8) else { throw KeychainError.unexpectedPasswordData } @@ -182,7 +194,7 @@ class KeychainHandler: NSObject { return keyData } - private func _keyScopeToKeyName(scope: KeyScope) -> String { - return "quiet_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)" + private func _keyScopeToKeyName(teamId: String, scope: KeyScope) -> String { + return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)" } } diff --git a/packages/mobile/ios/NotificationAppExtension/Info.plist b/packages/mobile/ios/NotificationAppExtension/Info.plist deleted file mode 100644 index 57421ebf9b..0000000000 --- a/packages/mobile/ios/NotificationAppExtension/Info.plist +++ /dev/null @@ -1,13 +0,0 @@ - - - - - NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - - - diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements b/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements deleted file mode 100644 index 7ac57ebda7..0000000000 --- a/packages/mobile/ios/NotificationAppExtension/NotificationAppExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - keychain-access-groups - - $(AppIdentifierPrefix)com.quietmobile - - - diff --git a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift b/packages/mobile/ios/NotificationAppExtension/NotificationService.swift deleted file mode 100644 index 0f337992d4..0000000000 --- a/packages/mobile/ios/NotificationAppExtension/NotificationService.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// NotificationService.swift -// NotificationAppExtension -// -// Created by Isla Koenigsknecht on 2/18/26. -// - -import UserNotifications - -class NotificationService: UNNotificationServiceExtension { - - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? - - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - if let bestAttemptContent = bestAttemptContent { - // Modify the notification content here... - bestAttemptContent.title = "\(bestAttemptContent.title) [modified]" - - contentHandler(bestAttemptContent) - } - } - - override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system. - // Use this as an opportunity to deliver your "best attempt" at modified content, otherwise the original push payload will be used. - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - contentHandler(bestAttemptContent) - } - } - -} diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 3503c544f8..dc4625a9bb 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -55,12 +55,11 @@ 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; }; 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; - 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */; }; - 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */; }; + 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 827250ED268E9BB42322847A /* libPods-Quiet.a */; }; + 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; - E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */; }; + E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -71,13 +70,6 @@ remoteGlobalIDString = 13B07F861A680F5B00A75B9A; remoteInfo = QuietMobile; }; - 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 665E5BBC2F46402C005D2086; - remoteInfo = NotificationAppExtension; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -93,17 +85,6 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; - 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 665E5BC42F46402C005D2086 /* NotificationAppExtension.appex in Embed Foundation Extensions */, - ); - name = "Embed Foundation Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -112,7 +93,6 @@ 00E356EE1AD99517003FC87E /* QuietTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = QuietTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 00E356F21AD99517003FC87E /* QuietTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QuietTests.m; sourceTree = ""; }; - 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 03B673F92E6103DC00A86655 /* Rubik-Black.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Black.ttf"; path = "../src/assets/fonts/Rubik-Black.ttf"; sourceTree = SOURCE_ROOT; }; 03B673FA2E6103DC00A86655 /* Rubik-BlackItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-BlackItalic.ttf"; path = "../src/assets/fonts/Rubik-BlackItalic.ttf"; sourceTree = SOURCE_ROOT; }; 03B673FB2E6103DC00A86655 /* Rubik-Bold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Bold.ttf"; path = "../src/assets/fonts/Rubik-Bold.ttf"; sourceTree = SOURCE_ROOT; }; @@ -127,8 +107,6 @@ 03B674042E6103DC00A86655 /* Rubik-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Regular.ttf"; path = "../src/assets/fonts/Rubik-Regular.ttf"; sourceTree = SOURCE_ROOT; }; 03B674052E6103DC00A86655 /* Rubik-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBold.ttf"; path = "../src/assets/fonts/Rubik-SemiBold.ttf"; sourceTree = SOURCE_ROOT; }; 03B674062E6103DC00A86655 /* Rubik-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBoldItalic.ttf"; path = "../src/assets/fonts/Rubik-SemiBoldItalic.ttf"; sourceTree = SOURCE_ROOT; }; - 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Quiet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quiet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 180E120A2AEFB7F900804659 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 1827A9E129783D6E00245FD3 /* classic-level.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = "classic-level.framework"; sourceTree = ""; }; @@ -640,36 +618,24 @@ 18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; }; 18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; }; 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; - 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; - 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationAppExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; - 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; + 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; + 827250ED268E9BB42322847A /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; - E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; + C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; + FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; + FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - -/* Begin PBXFileSystemSynchronizedRootGroup section */ - 665E5BBE2F46402C005D2086 /* NotificationAppExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (665E5BC82F46402D005D2086 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = NotificationAppExtension; sourceTree = ""; }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 00E356EB1AD99517003FC87E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */, - 38AD376324628C6E27E70991 /* libPods-Quiet-QuietTests.a in Frameworks */, + E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -679,14 +645,7 @@ files = ( 00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */, 1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */, - E479975299F0932FF690F805 /* libPods-Quiet.a in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 665E5BBA2F46402C005D2086 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( + 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -713,7 +672,7 @@ 13B07FAE1A68108700A75B9A /* Quiet */ = { isa = PBXGroup; children = ( - 6681DD392F4F53BF005D2086 /* KeychainHandler.swift */, + 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */, 180E120A2AEFB7F900804659 /* Utils.swift */, 18FD2A36296F009E00A2B8C0 /* AppDelegate.h */, 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */, @@ -4745,10 +4704,10 @@ 1CEEDB4F07B9978C125775C5 /* Pods */ = { isa = PBXGroup; children = ( - E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */, - 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */, - 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */, - 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */, + A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */, + C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */, + FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */, + A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -4758,8 +4717,8 @@ children = ( 00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */, 1827A9E129783D6E00245FD3 /* classic-level.framework */, - 104C0D0FA8EF00192C60CAD7 /* libPods-Quiet.a */, - 02AFA323BA7196A67E7A1133 /* libPods-Quiet-QuietTests.a */, + 827250ED268E9BB42322847A /* libPods-Quiet.a */, + FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */, ); name = Frameworks; sourceTree = ""; @@ -4799,7 +4758,6 @@ 13B07FAE1A68108700A75B9A /* Quiet */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* QuietTests */, - 665E5BBE2F46402C005D2086 /* NotificationAppExtension */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, 624523FCC5994B7E9869E9CF /* Resources */, @@ -4815,7 +4773,6 @@ children = ( 13B07F961A680F5B00A75B9A /* Quiet.app */, 00E356EE1AD99517003FC87E /* QuietTests.xctest */, - 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */, ); name = Products; sourceTree = ""; @@ -4827,12 +4784,12 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */; buildPhases = ( - 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */, + 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, - EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */, - 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */, + E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */, + C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4848,7 +4805,7 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */; buildPhases = ( - 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */, + 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, @@ -4860,42 +4817,18 @@ 18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */, 1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */, 1868C095292F8FE2001D6D5E /* Embed Frameworks */, - 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */, - A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */, - 665E5BC52F46402D005D2086 /* Embed Foundation Extensions */, + D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */, + 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( - 665E5BC32F46402C005D2086 /* PBXTargetDependency */, ); name = Quiet; productName = QuietMobile; productReference = 13B07F961A680F5B00A75B9A /* Quiet.app */; productType = "com.apple.product-type.application"; }; - 665E5BBC2F46402C005D2086 /* NotificationAppExtension */ = { - isa = PBXNativeTarget; - buildConfigurationList = 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */; - buildPhases = ( - 665E5BB92F46402C005D2086 /* Sources */, - 665E5BBA2F46402C005D2086 /* Frameworks */, - 665E5BBB2F46402C005D2086 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - fileSystemSynchronizedGroups = ( - 665E5BBE2F46402C005D2086 /* NotificationAppExtension */, - ); - name = NotificationAppExtension; - packageProductDependencies = ( - ); - productName = NotificationAppExtension; - productReference = 665E5BBD2F46402C005D2086 /* NotificationAppExtension.appex */; - productType = "com.apple.product-type.app-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -4903,7 +4836,6 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1620; LastUpgradeCheck = 2610; TargetAttributes = { 00E356ED1AD99517003FC87E = { @@ -4913,9 +4845,6 @@ 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1250; }; - 665E5BBC2F46402C005D2086 = { - CreatedOnToolsVersion = 16.2; - }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */; @@ -4933,7 +4862,6 @@ targets = ( 13B07F861A680F5B00A75B9A /* Quiet */, 00E356ED1AD99517003FC87E /* QuietTests */, - 665E5BBC2F46402C005D2086 /* NotificationAppExtension */, ); }; /* End PBXProject section */ @@ -4984,13 +4912,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 665E5BBB2F46402C005D2086 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -5006,7 +4927,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -e\n\n# Fix for machines using nvm\nif [[ -s \"$HOME/.nvm/nvm.sh\" ]]; then\n. \"$HOME/.nvm/nvm.sh\"\nelif [[ -x \"$(command -v brew)\" && -s \"$(brew --prefix nvm)/nvm.sh\" ]]; then\n. \"$(brew --prefix nvm)/nvm.sh\"\nfi\n\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; + shellScript = "set -e\n\nexport NODE_BINARY=/Users/isla/.volta/bin/node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; }; 03B673F82E60FE0000A86655 /* Inject Feature Flags */ = { isa = PBXShellScriptBuildPhase; @@ -5102,24 +5023,24 @@ shellPath = /bin/sh; shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n"; }; - 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */ = { + 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */ = { + 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5134,14 +5055,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */ = { + 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5156,48 +5077,48 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */ = { + C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; - A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */ = { + D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */ = { + E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5256,7 +5177,7 @@ 1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */, 1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */, 1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */, - 6681DD3A2F4F53BF005D2086 /* KeychainHandler.swift in Sources */, + 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */, 1868C43A2930D859001D6D5E /* FindFreePort.swift in Sources */, 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */, 1868BCED292E9212001D6D5E /* NodeRunner.mm in Sources */, @@ -5265,13 +5186,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 665E5BB92F46402C005D2086 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -5280,17 +5194,12 @@ target = 13B07F861A680F5B00A75B9A /* Quiet */; targetProxy = 00E356F41AD99517003FC87E /* PBXContainerItemProxy */; }; - 665E5BC32F46402C005D2086 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 665E5BBC2F46402C005D2086 /* NotificationAppExtension */; - targetProxy = 665E5BC22F46402C005D2086 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */; + baseConfigurationReference = FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -5323,7 +5232,7 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */; + baseConfigurationReference = A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -5353,7 +5262,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */; + baseConfigurationReference = A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -5454,7 +5363,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */; + baseConfigurationReference = C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -5548,91 +5457,6 @@ }; name = Release; }; - 665E5BC62F46402D005D2086 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = CTYKSWN9T4; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NotificationAppExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension; - PRODUCT_NAME = "$(TARGET_NAME)"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 665E5BC72F46402D005D2086 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = NotificationAppExtension/NotificationAppExtension.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = CTYKSWN9T4; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = NotificationAppExtension/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = NotificationAppExtension; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 18.2; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", - ); - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = com.quietmobile.NotificationAppExtension; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SKIP_INSTALL = YES; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -5802,15 +5626,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 665E5BC92F46402D005D2086 /* Build configuration list for PBXNativeTarget "NotificationAppExtension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 665E5BC62F46402D005D2086 /* Debug */, - 665E5BC72F46402D005D2086 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "Quiet" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 10c9b3370f..6a83e68746 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -16,9 +16,10 @@ import { PayloadAction } from '@reduxjs/toolkit' import { socket as stateManager, Socket } from '@quiet/state-manager' import { initActions, WebsocketConnectionPayload } from '../init.slice' import { eventChannel } from 'redux-saga' -import { SocketActions } from '@quiet/types' +import { KeysUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types' import { createLogger } from '../../../utils/logger' import { initSelectors } from '../init.selectors' +import { keysActions } from '../../keys/keys.slice' const logger = createLogger('startConnection') @@ -76,7 +77,9 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect let socket_id: string | undefined return eventChannel< - ReturnType | ReturnType + | ReturnType + | ReturnType + | ReturnType >(emit => { socket.on('connect', async () => { socket_id = socket.id @@ -87,6 +90,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.warn('client: Closing socket connection', socket_id, reason) emit(initActions.suspendWebsocketConnection()) }) + socket.on(SocketEvents.KEYS_UPDATED, async (payload: KeysUpdatedEvent) => { + logger.info('Keys updated, writing to keychain') + emit(keysActions.saveKeysInKeychain(payload)) + }) return () => {} }) } diff --git a/packages/mobile/src/store/keys/keys.type.ts b/packages/mobile/src/store/keys/keys.type.ts index b0e994fcfa..5148b443a9 100644 --- a/packages/mobile/src/store/keys/keys.type.ts +++ b/packages/mobile/src/store/keys/keys.type.ts @@ -8,4 +8,5 @@ export type ExtendedKeyScope = { export interface StorableKey { scope: ExtendedKeyScope key: string + teamId: string } diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts index b669b966aa..3e34095ef3 100644 --- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts +++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts @@ -12,53 +12,50 @@ import { StorableKey } from '../keys.type' const logger = createLogger('saveKeysInKeychainSaga') export function* saveKeysInKeychainSaga(action: PayloadAction): Generator { - logger.debug('Storing keys in ios keychain') + logger.info('Storing keys in ios keychain') const existingKeys = yield* select(keysSelectors.allKeys) const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key') const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key') const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key') - logger.debug('Updating keys state') + logger.info('Updating keys state') yield* put(keysActions.setKeys(action.payload)) - const newKeysPayload: KeysUpdatedEvent = { - secretKeys: newSecretKeys, - userPublicKeys: newUserPublicKeys, - sigKeys: newSigKeys, - } - const keysToSave: StorableKey[] = newSecretKeys.map( - keyWithMetadata => - ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'secret', - }, - key: keyWithMetadata.key, - } as StorableKey) - ) + const keysToSave: StorableKey[] = newSecretKeys.map(keyWithMetadata => ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'secret', + }, + key: keyWithMetadata.key, + teamId: action.payload.teamId, + })) keysToSave.push( - ...newUserPublicKeys.map( - keyWithMetadata => - ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'userPublic', - }, - key: keyWithMetadata.key, - } as StorableKey) - ) + ...newUserPublicKeys.map(keyWithMetadata => ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'userPublic', + }, + key: keyWithMetadata.key, + teamId: action.payload.teamId, + })) ) keysToSave.push( - ...newSigKeys.map( - keyWithMetadata => - ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'userSig', - }, - key: keyWithMetadata.key, - } as StorableKey) - ) + ...newSigKeys.map(keyWithMetadata => ({ + scope: { + ...keyWithMetadata.scope, + keyType: 'userSig', + }, + key: keyWithMetadata.key, + teamId: action.payload.teamId, + })) ) - logger.debug('Putting new keys in keychain', keysToSave) - yield* call(NativeModules.CommunicationModule.saveKeysInKeychain, newKeysPayload) + + logger.info('Putting new keys in keychain', keysToSave) + try { + yield* call( + NativeModules.CommunicationModule.saveKeysInKeychain, + keysToSave.map(key => JSON.stringify(key)) + ) + } catch (e) { + logger.error('Error while updating keys on keychain', e) + } } diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index 95e24fe0e2..c4e78fcc98 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -9,4 +9,5 @@ export interface KeysUpdatedEvent { secretKeys: KeyWithMetadata[] userPublicKeys: KeyWithMetadata[] sigKeys: KeyWithMetadata[] + teamId: string } From 76565d75417bbcbbbb0396e204bceb2bbe51f2f4 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 4 Mar 2026 14:15:58 -0500 Subject: [PATCH 04/92] Don't store in state manager, simplify model that is sent to frontend, decrease size of stored send data --- .../backend/src/nest/auth/sigchain.service.ts | 63 +++++--- packages/backend/src/nest/auth/types.ts | 6 + .../src/nest/local-db/local-db.service.ts | 13 ++ .../src/nest/local-db/local-db.types.ts | 3 + packages/mobile/ios/CommunicationModule.swift | 8 +- packages/mobile/ios/KeychainHandler.swift | 32 ++--- .../ios/Quiet.xcodeproj/project.pbxproj | 136 +++++++++--------- .../mobile/src/store/keys/keys.selectors..ts | 11 +- packages/mobile/src/store/keys/keys.slice.ts | 13 +- .../saveKeysInKeychain.saga.ts | 43 +----- packages/types/src/keys.ts | 9 +- 11 files changed, 151 insertions(+), 186 deletions(-) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 1b8392425d..7870aa3d67 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -11,11 +11,11 @@ import { UserWithSecrets, DeviceWithSecrets, } from '@localfirst/auth' -import { KeyMetadata, KeyType } from '@localfirst/crdx' +import { KeyMetadata } from '@localfirst/crdx' import { LocalDbService } from '../local-db/local-db.service' import { createLogger } from '../common/logger' import { SocketService } from '../socket/socket.service' -import { SocketEvents, User } from '@quiet/types' +import { SocketEvents, StorableKey, User } from '@quiet/types' import { type RoleService } from './services/roles/role.service' import { type DeviceService } from './services/members/device.service' import { type InviteService } from './services/invites/invite.service' @@ -24,10 +24,8 @@ import { type CryptoService } from './services/crypto/crypto.service' import { SERVER_IO_PROVIDER } from '../const' import { ServerIoProviderTypes } from '../types' import EventEmitter from 'events' -import { GetChainFilter } from './types' -import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types' -import { EncryptionScopeType } from './services/crypto/types' -import * as os from 'os' +import { GetChainFilter, StoredKeyType } from './types' +import { KeysUpdatedEvent } from '@quiet/types' @Injectable() export class SigChainService extends EventEmitter { @@ -166,44 +164,63 @@ export class SigChainService extends EventEmitter { } // TODO: only fetch keys that have been updated recently - private _updateKeysOnChainUpdate() { + private async _updateKeysOnChainUpdate() { if ((process.platform as string) !== 'ios') { this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform) return } - const secretKeys: KeyWithMetadata[] = [] - const sigKeys: KeyWithMetadata[] = [] - const userPublicKeys: KeyWithMetadata[] = [] + const generateKeyName = (teamId: string, keyType: string, scope: KeyMetadata): string => { + return `quiet_${teamId}_${scope.type}_${scope.name}_${scope.generation}_${keyType}` + } + + const teamId = this.activeChain.team!.id + const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId)) + const keysToSend: StorableKey[] = [] + const keyNamesSent: string[] = [] const allKeys = this.getActiveChain().crypto.getAllKeys() for (const keyData of Object.values(allKeys)) { for (const keyTypeData of Object.values(keyData)) { for (const keyTypeGenData of Object.values(keyTypeData)) { - secretKeys.push({ - scope: { name: keyTypeGenData.name, type: keyTypeGenData.type, generation: keyTypeGenData.generation }, - key: keyTypeGenData.secretKey, + const keyName = generateKeyName(teamId, StoredKeyType.SECRET, { + name: keyTypeGenData.name, + type: keyTypeGenData.type, + generation: keyTypeGenData.generation, }) + if (!alreadySentKeys.has(keyName)) { + keysToSend.push({ key: keyTypeGenData.secretKey, keyName }) + keyNamesSent.push(keyName) + } } } } // TODO: update to pull all generations of user public/sig keys const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true) for (const keySet of allUserPublicKeys) { - userPublicKeys.push({ - scope: { name: keySet.name, type: keySet.type, generation: keySet.generation }, - key: keySet.encryption, + const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, { + name: keySet.name, + type: keySet.type, + generation: keySet.generation, }) - sigKeys.push({ - scope: { name: keySet.name, type: keySet.type, generation: keySet.generation }, - key: keySet.signature, + if (!alreadySentKeys.has(publicKeyName)) { + keysToSend.push({ key: keySet.encryption, keyName: publicKeyName }) + keyNamesSent.push(publicKeyName) + } + + const sigKeyName = generateKeyName(teamId, StoredKeyType.USER_SIG, { + name: keySet.name, + type: keySet.type, + generation: keySet.generation, }) + if (!alreadySentKeys.has(sigKeyName)) { + keysToSend.push({ key: keySet.signature, keyName: sigKeyName }) + keyNamesSent.push(sigKeyName) + } } const keyUpdateEvent: KeysUpdatedEvent = { - secretKeys, - sigKeys, - userPublicKeys, - teamId: this.activeChain.team!.id, + keys: keysToSend, } + await this.localDbService.updateKeysStoredInKeychain(teamId, keyNamesSent) this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent) } diff --git a/packages/backend/src/nest/auth/types.ts b/packages/backend/src/nest/auth/types.ts index 438c6b0d46..42d7eda60a 100644 --- a/packages/backend/src/nest/auth/types.ts +++ b/packages/backend/src/nest/auth/types.ts @@ -17,3 +17,9 @@ export type GetChainFilter = { teamId?: string teamName?: string } + +export enum StoredKeyType { + SECRET = 'secret', + USER_PUBLIC = 'userPublic', + USER_SIG = 'userSig', +} diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts index b1cd84f38d..040c0af71d 100644 --- a/packages/backend/src/nest/local-db/local-db.service.ts +++ b/packages/backend/src/nest/local-db/local-db.service.ts @@ -605,4 +605,17 @@ export class LocalDbService extends EventEmitter { } return count } + + public async updateKeysStoredInKeychain(teamId: string, keyNames: string[]): Promise { + const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}` + const arr: string[] = (await this.get(key)) || [] + arr.push(...keyNames) + await this.put(key, arr) + } + + public async getKeysStoredInKeychain(teamId: string): Promise { + const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}` + const arr: string[] = (await this.get(key)) || [] + return arr + } } diff --git a/packages/backend/src/nest/local-db/local-db.types.ts b/packages/backend/src/nest/local-db/local-db.types.ts index e0ccf2236b..1ddf242ae5 100644 --- a/packages/backend/src/nest/local-db/local-db.types.ts +++ b/packages/backend/src/nest/local-db/local-db.types.ts @@ -38,6 +38,9 @@ export enum LocalDBKeys { // exists in the Community object. OWNER_ORBIT_DB_IDENTITY = 'ownerOrbitDbIdentity', + // Keys from sigchain that have been stored in keychain + KEYS_STORED_KEYCHAIN = 'keysStoredInKeychain', + SIGCHAINS = 'sigchains:', USER_CONTEXTS = 'userContexts', KEYRINGS = 'keyrings', diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 71cb912914..daa596255b 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -67,10 +67,10 @@ class CommunicationModule: RCTEventEmitter { do { let keyAsString: String = keyAsAny as! String let data = Data(keyAsString.utf8) - let decodedKeyWithScope = try decoder.decode(KeyWithScope.self, from: data) - try self.keychainHandler.addLfaKey(keyWithScope: decodedKeyWithScope) - let stored = try self.keychainHandler.getLfaKeyString(teamId: decodedKeyWithScope.teamId, scope: decodedKeyWithScope.scope) - CommunicationModule.logger.info("Stored key matches? \(stored == decodedKeyWithScope.key) \(String(describing: decodedKeyWithScope.scope))") + let decodedNamedKey = try decoder.decode(NamedKey.self, from: data) + try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey) + let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName) + CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)") } catch { CommunicationModule.logger.error("Error while saving key in keychain: \(error)") } diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index cad13112bf..3b86f1e5e9 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -28,22 +28,14 @@ public enum KeychainHandlerError: Error { case unhandledError(reason: Any) } -public struct KeyScope: Codable { - let name: String - let generation: Int - let type: String - let keyType: String -} - public enum KeyAddStatus { case success case duplicateScope } -public struct KeyWithScope: Codable { - let scope: KeyScope +public struct NamedKey: Codable { + let keyName: String let key: String - let teamId: String } @objc(KeychainHandler) @@ -69,9 +61,8 @@ class KeychainHandler: NSObject { } } - public func getLfaKeyString(teamId: String, scope: KeyScope) throws -> String { + public func getLfaKeyString(keyName: String) throws -> String { do { - let keyName: String = _keyScopeToKeyName(teamId: teamId, scope: scope) let password: String = try _getKeyImpl(keyName: keyName) return password } catch KeychainError.noPassword { @@ -107,28 +98,27 @@ class KeychainHandler: NSObject { } } - public func addLfaKey(keyWithScope: KeyWithScope) throws -> KeyAddStatus { + public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus { var existingKey: String? do { - existingKey = try getLfaKeyString(teamId: keyWithScope.teamId, scope: keyWithScope.scope) + existingKey = try getLfaKeyString(keyName: namedKey.keyName) } catch KeychainHandlerError.noKeyFound { existingKey = nil } catch KeychainHandlerError.malformedKey { existingKey = nil } catch { - KeychainHandler.logger.error("Error while getting existing LFA key for scope \(String(describing: keyWithScope.scope)): \(error)") + KeychainHandler.logger.error("Error while getting existing LFA key for name \(namedKey.keyName): \(error)") throw error } guard existingKey == nil else { - guard existingKey == keyWithScope.key else { return KeyAddStatus.duplicateScope } + guard existingKey == namedKey.key else { return KeyAddStatus.duplicateScope } return KeyAddStatus.success } do { - let keyName: String = _keyScopeToKeyName(teamId: keyWithScope.teamId, scope: keyWithScope.scope) - let keyData: Data = try _stringToBytes(str: keyWithScope.key) - let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: keyData) + let keyData: Data = try _stringToBytes(str: namedKey.key) + let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: namedKey.keyName, keyData: keyData) return addStatus } catch { throw KeychainHandlerError.unhandledError(reason: error) @@ -193,8 +183,4 @@ class KeychainHandler: NSObject { } return keyData } - - private func _keyScopeToKeyName(teamId: String, scope: KeyScope) -> String { - return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_\(scope.keyType)" - } } diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index dc4625a9bb..e1d226dc60 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -55,11 +55,11 @@ 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; }; 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; - 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 827250ED268E9BB42322847A /* libPods-Quiet.a */; }; 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; }; + 85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 287A03B12772D68AAF979A77 /* libPods-Quiet.a */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; - E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */; }; + FFD33AE2521CEA2E0D3134ED /* libPods-Quiet-QuietTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -618,15 +618,15 @@ 18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; }; 18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; }; 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; + 287A03B12772D68AAF979A77 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; - 827250ED268E9BB42322847A /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; + 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; - A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; - A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; - C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; - FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; - FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet-QuietTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -635,7 +635,7 @@ buildActionMask = 2147483647; files = ( 1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */, - E527867D4810B57F475B8709 /* libPods-Quiet-QuietTests.a in Frameworks */, + FFD33AE2521CEA2E0D3134ED /* libPods-Quiet-QuietTests.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -645,7 +645,7 @@ files = ( 00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */, 1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */, - 36A82BC8FCE690814036F90F /* libPods-Quiet.a in Frameworks */, + 85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4704,10 +4704,10 @@ 1CEEDB4F07B9978C125775C5 /* Pods */ = { isa = PBXGroup; children = ( - A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */, - C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */, - FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */, - A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */, + 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */, + 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */, + 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */, + 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -4717,8 +4717,8 @@ children = ( 00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */, 1827A9E129783D6E00245FD3 /* classic-level.framework */, - 827250ED268E9BB42322847A /* libPods-Quiet.a */, - FFB5AD1B1D1B895AC31572D9 /* libPods-Quiet-QuietTests.a */, + 287A03B12772D68AAF979A77 /* libPods-Quiet.a */, + CA54D67D8C7A248C6A22C0F8 /* libPods-Quiet-QuietTests.a */, ); name = Frameworks; sourceTree = ""; @@ -4784,12 +4784,12 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */; buildPhases = ( - 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */, + 95F7BA6D16EB3A1B27BAA786 /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, - E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */, - C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */, + 911E369A1C6D85907F7F0302 /* [CP] Embed Pods Frameworks */, + E7F2776DE3547BE1502BC947 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4805,7 +4805,7 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */; buildPhases = ( - 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */, + BB9294C6673D9CCB22CBA0B8 /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, @@ -4817,8 +4817,8 @@ 18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */, 1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */, 1868C095292F8FE2001D6D5E /* Embed Frameworks */, - D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */, - 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */, + 85BBD2CD345055BC7227A72B /* [CP] Embed Pods Frameworks */, + 00462B5D3156F56526040199 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4915,6 +4915,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 00462B5D3156F56526040199 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -5023,46 +5040,41 @@ shellPath = /bin/sh; shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n"; }; - 51FD2183E5DD9C7BD49270BD /* [CP] Copy Pods Resources */ = { + 85BBD2CD345055BC7227A72B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 626C6584D894673F46A360BF /* [CP] Check Pods Manifest.lock */ = { + 911E369A1C6D85907F7F0302 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 69006B3CE96CD13A5F5A5226 /* [CP] Check Pods Manifest.lock */ = { + 95F7BA6D16EB3A1B27BAA786 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5084,55 +5096,43 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C3AB99BB8D1CD10850476367 /* [CP] Copy Pods Resources */ = { + BB9294C6673D9CCB22CBA0B8 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - D141249F8994850B1DC7FF46 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Quiet-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - E49E02522FA800C59F2F014D /* [CP] Embed Pods Frameworks */ = { + E7F2776DE3547BE1502BC947 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; showEnvVarsInLog = 0; }; FD10A7F022414F080027D42C /* Start Packager */ = { @@ -5199,7 +5199,7 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = FB3F0E6671064CAF2C159B5D /* Pods-Quiet-QuietTests.debug.xcconfig */; + baseConfigurationReference = 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -5232,7 +5232,7 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A5B75BBA2D210014081EFE0C /* Pods-Quiet-QuietTests.release.xcconfig */; + baseConfigurationReference = 88972EF0A58F6401DC66730D /* Pods-Quiet-QuietTests.release.xcconfig */; buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; @@ -5262,7 +5262,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A0A640C5FB25A885E7EA3D28 /* Pods-Quiet.debug.xcconfig */; + baseConfigurationReference = 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -5363,7 +5363,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C7B1A71CADBC729F3E550DF1 /* Pods-Quiet.release.xcconfig */; + baseConfigurationReference = 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; diff --git a/packages/mobile/src/store/keys/keys.selectors..ts b/packages/mobile/src/store/keys/keys.selectors..ts index e6016ba4c7..647e429649 100644 --- a/packages/mobile/src/store/keys/keys.selectors..ts +++ b/packages/mobile/src/store/keys/keys.selectors..ts @@ -1,15 +1,6 @@ -import { createSelector } from 'reselect' import { StoreKeys } from '../store.keys' import { CreatedSelectors, StoreState } from '../store.types' const keysSlice: CreatedSelectors[StoreKeys.Keys] = (state: StoreState) => state[StoreKeys.Keys] -export const allKeys = createSelector(keysSlice, state => ({ - secretKeys: state.secretKeys, - userPublicKeys: state.userPublicKeys, - sigKeys: state.sigKeys, -})) - -export const keysSelectors = { - allKeys, -} +export const keysSelectors = {} diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts index c69ef0ec9b..884b147bcf 100644 --- a/packages/mobile/src/store/keys/keys.slice.ts +++ b/packages/mobile/src/store/keys/keys.slice.ts @@ -1,25 +1,16 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { StoreKeys } from '../store.keys' -import { KeysUpdatedEvent, KeyWithMetadata } from '@quiet/types' +import { KeysUpdatedEvent } from '@quiet/types' import { createLogger } from '../../utils/logger' const logger = createLogger('keysSlice') -export class KeysState { - public secretKeys: KeyWithMetadata[] = [] - public userPublicKeys: KeyWithMetadata[] = [] - public sigKeys: KeyWithMetadata[] = [] -} +export class KeysState {} export const keysSlice = createSlice({ initialState: { ...new KeysState() }, name: StoreKeys.Keys, reducers: { - setKeys: (state, action: PayloadAction) => { - state.secretKeys = action.payload.secretKeys - state.sigKeys = action.payload.sigKeys - state.userPublicKeys = action.payload.userPublicKeys - }, saveKeysInKeychain: (state, _action: PayloadAction) => state, }, }) diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts index 3e34095ef3..ae94020c36 100644 --- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts +++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts @@ -2,8 +2,6 @@ import { type PayloadAction } from '@reduxjs/toolkit' import { call, select, put } from 'typed-redux-saga' import { KeysUpdatedEvent } from '@quiet/types' import { createLogger } from '../../../utils/logger' -import { keysActions } from '../keys.slice' -import { keysSelectors } from '../keys.selectors.' import _ from 'lodash' import { NativeModules } from 'react-native' @@ -12,48 +10,11 @@ import { StorableKey } from '../keys.type' const logger = createLogger('saveKeysInKeychainSaga') export function* saveKeysInKeychainSaga(action: PayloadAction): Generator { - logger.info('Storing keys in ios keychain') - const existingKeys = yield* select(keysSelectors.allKeys) - const newSecretKeys = _.differenceBy(action.payload.secretKeys, existingKeys.secretKeys, 'key') - const newUserPublicKeys = _.differenceBy(action.payload.userPublicKeys, existingKeys.userPublicKeys, 'key') - const newSigKeys = _.differenceBy(action.payload.sigKeys, existingKeys.sigKeys, 'key') - logger.info('Updating keys state') - yield* put(keysActions.setKeys(action.payload)) - - const keysToSave: StorableKey[] = newSecretKeys.map(keyWithMetadata => ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'secret', - }, - key: keyWithMetadata.key, - teamId: action.payload.teamId, - })) - keysToSave.push( - ...newUserPublicKeys.map(keyWithMetadata => ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'userPublic', - }, - key: keyWithMetadata.key, - teamId: action.payload.teamId, - })) - ) - keysToSave.push( - ...newSigKeys.map(keyWithMetadata => ({ - scope: { - ...keyWithMetadata.scope, - keyType: 'userSig', - }, - key: keyWithMetadata.key, - teamId: action.payload.teamId, - })) - ) - - logger.info('Putting new keys in keychain', keysToSave) + logger.info('Storing keys in ios keychain', action.payload.keys) try { yield* call( NativeModules.CommunicationModule.saveKeysInKeychain, - keysToSave.map(key => JSON.stringify(key)) + action.payload.keys.map(key => JSON.stringify(key)) ) } catch (e) { logger.error('Error while updating keys on keychain', e) diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index c4e78fcc98..7939aac759 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -1,13 +1,10 @@ import { Base58, KeyMetadata } from '@localfirst/crdx' -export interface KeyWithMetadata { - scope: KeyMetadata +export interface StorableKey { + keyName: string key: string | Base58 } export interface KeysUpdatedEvent { - secretKeys: KeyWithMetadata[] - userPublicKeys: KeyWithMetadata[] - sigKeys: KeyWithMetadata[] - teamId: string + keys: StorableKey[] } From 12d20d2e6623c992689087b442e6d0a243f57b99 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:51:42 -0500 Subject: [PATCH 05/92] Add comments --- .../backend/src/nest/auth/sigchain.service.ts | 18 ++++++++++++++++-- .../src/nest/local-db/local-db.service.ts | 12 ++++++++++++ .../saveKeysInKeychain.saga.ts | 8 +++----- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 7870aa3d67..bed41eff38 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -151,6 +151,9 @@ export class SigChainService extends EventEmitter { this.logger.info('Chain updated, emitted updated event') } + /** + * Send updated list of users to the state manager on chain update + */ private _updateUsersOnChainUpdate() { const users = this.getActiveChain() .team?.members() @@ -163,8 +166,10 @@ export class SigChainService extends EventEmitter { this.serverIoProvider.io.emit(SocketEvents.USERS_UPDATED, { users }) } - // TODO: only fetch keys that have been updated recently - private async _updateKeysOnChainUpdate() { + /** + * Update the IOS keychain with any new keys on chain update + */ + private async _updateKeysOnChainUpdate(): Promise { if ((process.platform as string) !== 'ios') { this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform) return @@ -178,6 +183,7 @@ export class SigChainService extends EventEmitter { const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId)) const keysToSend: StorableKey[] = [] const keyNamesSent: string[] = [] + // get all secret keys that this user has that haven't been added to the keychain const allKeys = this.getActiveChain().crypto.getAllKeys() for (const keyData of Object.values(allKeys)) { for (const keyTypeData of Object.values(keyData)) { @@ -195,6 +201,7 @@ export class SigChainService extends EventEmitter { } } // TODO: update to pull all generations of user public/sig keys + // get all user public keys that haven't been added to the keychain const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true) for (const keySet of allUserPublicKeys) { const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, { @@ -217,6 +224,13 @@ export class SigChainService extends EventEmitter { keyNamesSent.push(sigKeyName) } } + + if (keysToSend.length === 0) { + this.logger.trace('Skipping IOS keychain update, no new keys') + return + } + + // send new keys to the state manager to add to the keychain and update list of key names in local DB const keyUpdateEvent: KeysUpdatedEvent = { keys: keysToSend, } diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts index 040c0af71d..d1209bcb12 100644 --- a/packages/backend/src/nest/local-db/local-db.service.ts +++ b/packages/backend/src/nest/local-db/local-db.service.ts @@ -606,6 +606,12 @@ export class LocalDbService extends EventEmitter { return count } + /** + * Update list of kys for a given team ID that were stored in the IOS keychain + * + * @param teamId LFA team ID + * @param keyNames Names of keys that were added to IOS keychain + */ public async updateKeysStoredInKeychain(teamId: string, keyNames: string[]): Promise { const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}` const arr: string[] = (await this.get(key)) || [] @@ -613,6 +619,12 @@ export class LocalDbService extends EventEmitter { await this.put(key, arr) } + /** + * Get the list of key names for a given team ID that have been stored in the IOS keychain + * + * @param teamId LFA team ID + * @returns List of key names + */ public async getKeysStoredInKeychain(teamId: string): Promise { const key = `${LocalDBKeys.KEYS_STORED_KEYCHAIN}:${teamId}` const arr: string[] = (await this.get(key)) || [] diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts index ae94020c36..45c6465713 100644 --- a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts +++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.ts @@ -1,12 +1,10 @@ import { type PayloadAction } from '@reduxjs/toolkit' -import { call, select, put } from 'typed-redux-saga' +import { call } from 'typed-redux-saga' +import { NativeModules } from 'react-native' + import { KeysUpdatedEvent } from '@quiet/types' import { createLogger } from '../../../utils/logger' -import _ from 'lodash' -import { NativeModules } from 'react-native' -import { StorableKey } from '../keys.type' - const logger = createLogger('saveKeysInKeychainSaga') export function* saveKeysInKeychainSaga(action: PayloadAction): Generator { From c4a2a23faa2b025ae8faf1888c29ec93d7c4a974 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:53:26 -0500 Subject: [PATCH 06/92] Update CommunicationModule.swift --- packages/mobile/ios/CommunicationModule.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index daa596255b..09c7b62330 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -72,6 +72,7 @@ class CommunicationModule: RCTEventEmitter { let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName) CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)") } catch { + // TODO: send a message to the backend with any keys that weren't stored CommunicationModule.logger.error("Error while saving key in keychain: \(error)") } } From 62978afaa735e2f851af5b1e382d8b21a1fc3b9a Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:59:04 -0500 Subject: [PATCH 07/92] Update KeychainHandler.swift --- packages/mobile/ios/KeychainHandler.swift | 52 +---------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index 3b86f1e5e9..d478d84948 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -38,29 +38,13 @@ public struct NamedKey: Codable { let key: String } +// TODO: add string to key object conversion (e.g. string to SymmetricKey) @objc(KeychainHandler) class KeychainHandler: NSObject { - private let masterKeyName: String = "quiet_master_key" private let keychainGroupName: String = "com.quietmobile" private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler") - public func getMasterKey() throws -> SymmetricKey { - do { - let password: String = try _getKeyImpl(keyName: masterKeyName) - let passwordBytes: ContiguousBytes = try _stringToBytes(str: password) - return SymmetricKey(data: passwordBytes) - } catch KeychainError.noPassword { - throw KeychainHandlerError.noKeyFound - } catch KeychainError.unexpectedPasswordData { - throw KeychainHandlerError.malformedKey - } catch ConversionError.stringToBytesError { - throw KeychainHandlerError.malformedKey - } catch { - throw KeychainHandlerError.unhandledError(reason: error) - } - } - public func getLfaKeyString(keyName: String) throws -> String { do { let password: String = try _getKeyImpl(keyName: keyName) @@ -76,28 +60,6 @@ class KeychainHandler: NSObject { } } - public func createMasterKey() throws -> SymmetricKey { - var existingKey: SymmetricKey? - do { - existingKey = try getMasterKey() - } catch KeychainHandlerError.noKeyFound { - existingKey = nil - } catch { - throw error - } - - guard existingKey == nil else { return existingKey! } - do { - let newKey: SymmetricKey = _generateAESKey() - let keyData: Data = _symmetricKeyToData(key: newKey) - let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: masterKeyName, keyData: keyData) - guard addStatus == KeyAddStatus.success else { throw KeychainHandlerError.unhandledError(reason: addStatus) } - return newKey - } catch { - throw KeychainHandlerError.unhandledError(reason: error) - } - } - public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus { var existingKey: String? do { @@ -166,21 +128,9 @@ class KeychainHandler: NSObject { } } - private func _generateAESKey() -> SymmetricKey { - let key: SymmetricKey = SymmetricKey(size: .bits256) - return key - } - private func _stringToBytes(str: String) throws -> Data { let bytes: Data? = str.data(using: .utf8) guard bytes != nil else { throw ConversionError.stringToBytesError } return bytes! } - - private func _symmetricKeyToData(key: SymmetricKey) -> Data { - let keyData: Data = key.withUnsafeBytes { body in - Data(body) - } - return keyData - } } From 10f5549956156280b7c1452e398cad86f4fab418 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:00:32 -0500 Subject: [PATCH 08/92] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b875a72d32..9c85856f40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ * Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) * Use LFA-based identity in OrbitDB * Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) +* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) ### Fixes From dcc4bfcb86cbd0db17a5a7afd75394d6d8fd1304 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:42:23 -0500 Subject: [PATCH 09/92] Pass actual team name since updates can happen when creating a chain before actually setting it active --- .../backend/src/nest/auth/sigchain.service.ts | 47 +++++++++++-------- packages/backend/src/nest/qss/qss.service.ts | 8 ++-- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index bed41eff38..cdaacd35ee 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -14,7 +14,6 @@ import { import { KeyMetadata } from '@localfirst/crdx' import { LocalDbService } from '../local-db/local-db.service' import { createLogger } from '../common/logger' -import { SocketService } from '../socket/socket.service' import { SocketEvents, StorableKey, User } from '@quiet/types' import { type RoleService } from './services/roles/role.service' import { type DeviceService } from './services/members/device.service' @@ -33,12 +32,10 @@ export class SigChainService extends EventEmitter { private readonly logger = createLogger(SigChainService.name) private chains: Map = new Map() public connections: Map = new Map() - private lastUpdatedLink: Hash constructor( @Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes, - private readonly localDbService: LocalDbService, - private readonly socketService: SocketService + private readonly localDbService: LocalDbService ) { super() } @@ -143,19 +140,19 @@ export class SigChainService extends EventEmitter { this.attachSocketListeners(this.getChain({ teamName })) } - private handleChainUpdate = () => { - this._updateUsersOnChainUpdate() - this._updateKeysOnChainUpdate() - this.emit('updated') - this.saveChain(this.activeChainTeamName!) + private handleChainUpdate = (teamName: string) => { + this._updateUsersOnChainUpdate(teamName) + this._updateKeysOnChainUpdate(teamName) + this.emit('updated', teamName) + this.saveChain(teamName) this.logger.info('Chain updated, emitted updated event') } /** * Send updated list of users to the state manager on chain update */ - private _updateUsersOnChainUpdate() { - const users = this.getActiveChain() + private _updateUsersOnChainUpdate(teamName: string) { + const users = this.getChain({ teamName }) .team?.members() .map(user => ({ userId: user.userId, @@ -169,7 +166,7 @@ export class SigChainService extends EventEmitter { /** * Update the IOS keychain with any new keys on chain update */ - private async _updateKeysOnChainUpdate(): Promise { + private async _updateKeysOnChainUpdate(teamName: string): Promise { if ((process.platform as string) !== 'ios') { this.logger.trace('Skipping key update because we are not on ios, current platform =', process.platform) return @@ -179,12 +176,18 @@ export class SigChainService extends EventEmitter { return `quiet_${teamId}_${scope.type}_${scope.name}_${scope.generation}_${keyType}` } - const teamId = this.activeChain.team!.id + const sigchain = this.getChain({ teamName }) + if (sigchain == null) { + this.logger.error('No chain for name found', teamName) + return + } + + const teamId = sigchain.team!.id const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId)) const keysToSend: StorableKey[] = [] const keyNamesSent: string[] = [] // get all secret keys that this user has that haven't been added to the keychain - const allKeys = this.getActiveChain().crypto.getAllKeys() + const allKeys = sigchain.crypto.getAllKeys() for (const keyData of Object.values(allKeys)) { for (const keyTypeData of Object.values(keyData)) { for (const keyTypeGenData of Object.values(keyTypeData)) { @@ -202,7 +205,7 @@ export class SigChainService extends EventEmitter { } // TODO: update to pull all generations of user public/sig keys // get all user public keys that haven't been added to the keychain - const allUserPublicKeys = this.getActiveChain().crypto.getPublicKeysForAllMembers(true) + const allUserPublicKeys = sigchain.crypto.getPublicKeysForAllMembers(true) for (const keySet of allUserPublicKeys) { const publicKeyName = generateKeyName(teamId, StoredKeyType.USER_PUBLIC, { name: keySet.name, @@ -230,7 +233,7 @@ export class SigChainService extends EventEmitter { return } - // send new keys to the state manager to add to the keychain and update list of key names in local DB + // send new keys to the state manager to add to the keychain and update list of key names in const keyUpdateEvent: KeysUpdatedEvent = { keys: keysToSend, } @@ -240,12 +243,18 @@ export class SigChainService extends EventEmitter { private attachSocketListeners(chain: SigChain): void { this.logger.info('Attaching socket listeners') - chain.on('updated', this.handleChainUpdate) + const _onTeamUpdate = (): void => { + this.handleChainUpdate(chain.team!.teamName) + } + chain.on('updated', _onTeamUpdate) } private detachSocketListeners(chain: SigChain): void { this.logger.info('Detaching socket listeners') - chain.removeListener('updated', this.handleChainUpdate) + const _onTeamUpdate = (): void => { + this.handleChainUpdate(chain.team!.teamName) + } + chain.removeListener('updated', _onTeamUpdate) } /** @@ -299,7 +308,7 @@ export class SigChainService extends EventEmitter { const sigChain = SigChain.create(teamName, username) this.addChain(sigChain, setActive, teamName) await this.saveChain(teamName) - this.handleChainUpdate() + this.handleChainUpdate(teamName) return sigChain } diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 9a06a8b120..0a67374320 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -105,7 +105,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this._deadLetterQueueProcessor = setInterval(this.processDeadLetterQueue, 30_000) this.connect = this.connect.bind(this) this._configureEventHandlers() - this.sigChainService.on('updated', () => void this.processDLQDecrypt()) + this.sigChainService.on('updated', (teamName: string) => void this.processDLQDecrypt(teamName)) } public onModuleDestroy() { @@ -907,14 +907,14 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul /** * Process the decryption dead letter queue when sigchain updates (new keys arrive) */ - private async processDLQDecrypt(): Promise { + private async processDLQDecrypt(teamName: string): Promise { if (this._dlqDecryptInFlight) { this.logger.debug('DLQ decrypt already in progress, requesting retry') this._dlqDecryptRetryRequested = true return } - const activeChain = this.sigChainService.getActiveChain(false) + const activeChain = this.sigChainService.getChain({ teamName }) if (!activeChain?.team) { return } @@ -981,7 +981,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul // If a sigchain update occurred while processing, retry with new keys if (this._dlqDecryptRetryRequested) { this.logger.debug('Retrying DLQ decrypt after sigchain update during processing') - await this.processDLQDecrypt() + await this.processDLQDecrypt(teamName) } } From 406ef6b5b5757bd3de0ed5aa20f7bd8d895d9b30 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 11 Mar 2026 14:23:18 -0400 Subject: [PATCH 10/92] Move update user profiles to a separate saga --- .../userProfile/updateUserProfiles.saga.ts | 52 +++++++++++++++++++ .../src/sagas/users/users.master.saga.ts | 2 + .../src/sagas/users/users.slice.ts | 41 +-------------- 3 files changed, 55 insertions(+), 40 deletions(-) create mode 100644 packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts new file mode 100644 index 0000000000..0b8cc40197 --- /dev/null +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -0,0 +1,52 @@ +import { PayloadAction } from '@reduxjs/toolkit' +import { createLogger } from '../../../utils/logger' +import { put, select } from 'typed-redux-saga' +import { userProfileSelectors } from './userProfile.selectors' +import { UserProfile } from '@quiet/types' +import { Socket } from '../../../types' +import { usersActions } from '../users.slice' + +const logger = createLogger('updateUserProfilesSaga') + +export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator { + logger.info(`Updating user profiles (profile count = ${action.payload.length}`) + const userProfiles = yield* select(userProfileSelectors.userProfiles) + const updates = { ...userProfiles } + for (const userProfile of action.payload) { + if (updates[userProfile.userId]) { + const existingProfile = updates[userProfile.userId] + + const updatedProfile = { + ...existingProfile, + ...userProfile, + } + + // If CID is the same, preserve the existing path + if ( + userProfile.profilePhoto?.cid && + existingProfile.profilePhoto?.cid === userProfile.profilePhoto.cid && + existingProfile.profilePhoto?.path + ) { + updatedProfile.profilePhoto = { + ...userProfile.profilePhoto, + path: existingProfile.profilePhoto.path, + } + } + + // If CID changed, ensure path is null (it should be null from userProfile anyway, but let's be explicit) + if (userProfile.profilePhoto?.cid && existingProfile.profilePhoto?.cid !== userProfile.profilePhoto.cid) { + updatedProfile.profilePhoto = { + ...userProfile.profilePhoto, + path: null, + } + } + + updates[userProfile.userId] = updatedProfile + } else { + updates[userProfile.userId] = userProfile + } + } + logger.debug(`Updating user profiles in redux store`) + yield* put(usersActions.setUserProfiles(Object.values(updates))) + logger.debug(`Done`) +} diff --git a/packages/state-manager/src/sagas/users/users.master.saga.ts b/packages/state-manager/src/sagas/users/users.master.saga.ts index 533dde98e1..cbbe6e0639 100644 --- a/packages/state-manager/src/sagas/users/users.master.saga.ts +++ b/packages/state-manager/src/sagas/users/users.master.saga.ts @@ -5,6 +5,7 @@ import { usersActions } from './users.slice' import { saveUserProfileSaga } from './userProfile/saveUserProfile.saga' import { downloadProfilePhotosSaga } from './userProfile/downloadProfilePhotos.saga' import { createLogger } from '../../utils/logger' +import { updateUserProfilesSaga } from './userProfile/updateUserProfiles.saga' const logger = createLogger('usersMasterSaga') @@ -13,6 +14,7 @@ export function* usersMasterSaga(socket: Socket): Generator { try { yield all([ takeEvery(usersActions.saveUserProfile.type, saveUserProfileSaga, socket), + takeEvery(usersActions.updateUserProfiles.type, updateUserProfilesSaga, socket), takeEvery(usersActions.updateUserProfiles.type, downloadProfilePhotosSaga), ]) } finally { diff --git a/packages/state-manager/src/sagas/users/users.slice.ts b/packages/state-manager/src/sagas/users/users.slice.ts index d253d51f37..54064031b7 100644 --- a/packages/state-manager/src/sagas/users/users.slice.ts +++ b/packages/state-manager/src/sagas/users/users.slice.ts @@ -40,46 +40,7 @@ export const usersSlice = createSlice({ } return state }, - updateUserProfiles: (state, action: PayloadAction) => { - if (!state.userProfiles) { - state.userProfiles = {} - } - for (const userProfile of action.payload) { - if (state.userProfiles[userProfile.userId]) { - const existingProfile = state.userProfiles[userProfile.userId] - - const updatedProfile = { - ...existingProfile, - ...userProfile, - } - - // If CID is the same, preserve the existing path - if ( - userProfile.profilePhoto?.cid && - existingProfile.profilePhoto?.cid === userProfile.profilePhoto.cid && - existingProfile.profilePhoto?.path - ) { - updatedProfile.profilePhoto = { - ...userProfile.profilePhoto, - path: existingProfile.profilePhoto.path, - } - } - - // If CID changed, ensure path is null (it should be null from userProfile anyway, but let's be explicit) - if (userProfile.profilePhoto?.cid && existingProfile.profilePhoto?.cid !== userProfile.profilePhoto.cid) { - updatedProfile.profilePhoto = { - ...userProfile.profilePhoto, - path: null, - } - } - - state.userProfiles[userProfile.userId] = updatedProfile - } else { - state.userProfiles[userProfile.userId] = userProfile - } - } - return state - }, + updateUserProfiles: (state, _action: PayloadAction) => state, // Sets a single user profile, overwriting the existing one setUserProfile: (state, action: PayloadAction) => { // Creating user profiles object for backwards compatibility with 2.0.1 From f9391fcb98ead84a0c75f2982106b37beebbcb34 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:30:01 -0400 Subject: [PATCH 11/92] Pass updated user profiles to native ios code --- .../connections-manager.service.ts | 6 +++++ .../backend/src/nest/socket/socket.service.ts | 6 +++++ packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 24 +++++++++++++++++++ .../ios/Quiet.xcodeproj/project.pbxproj | 4 ++++ packages/mobile/ios/UserMetadataHandler.swift | 24 +++++++++++++++++++ .../startConnection/startConnection.saga.ts | 8 ++++++- packages/mobile/src/store/root.reducer.ts | 2 ++ packages/mobile/src/store/root.saga.ts | 4 +++- packages/mobile/src/store/store.keys.ts | 1 + .../saveUserMetadataNatively.saga.ts | 18 ++++++++++++++ .../userMetadata/usersMetadata.master.saga.ts | 19 +++++++++++++++ .../userMetadata/usersMetadata.selectors..ts | 7 ++++++ .../store/userMetadata/usersMetadata.slice.ts | 19 +++++++++++++++ .../userMetadata/usersMetadata.transform.ts | 14 +++++++++++ .../store/userMetadata/usersMetadata.types.ts | 12 ++++++++++ .../startConnection/startConnection.saga.ts | 1 + .../userProfile/updateUserProfiles.saga.ts | 23 ++++++++++++------ packages/types/src/socket.ts | 5 ++++ packages/types/src/user.ts | 4 ++++ 20 files changed, 193 insertions(+), 9 deletions(-) create mode 100644 packages/mobile/ios/UserMetadataHandler.swift create mode 100644 packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.slice.ts create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.transform.ts create mode 100644 packages/mobile/src/store/userMetadata/usersMetadata.types.ts diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index ff03f1a5ec..45aa25c4fc 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -50,6 +50,7 @@ import { SetUserProfilePayload, InvitationData, SetUserProfileResponse, + UserProfilesUpdatedPayload, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -809,6 +810,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI } ) + this.socketService.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => { + this.logger.info(`Forwarding ${SocketActions.USER_PROFILES_UPDATED} back to state manager`) + this.serverIoProvider.io.emit(SocketEvents.USER_PROFILES_UPDATED, payload) + }) + this.socketService.on(SocketActions.TOGGLE_P2P, async (payload: boolean, callback: (response: boolean) => void) => { try { if (payload) { diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index 18506f70d0..aefd7aeaa8 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -24,6 +24,7 @@ import { SetUserProfilePayload, type HCaptchaFormResponse, InviteResultWithSalt, + UserProfilesUpdatedPayload, } from '@quiet/types' import EventEmitter from 'events' import { CONFIG_OPTIONS, SERVER_IO_PROVIDER } from '../const' @@ -199,6 +200,11 @@ export class SocketService extends EventEmitter implements OnModuleInit { } ) + socket.on(SocketActions.USER_PROFILES_UPDATED, (payload: UserProfilesUpdatedPayload) => { + this.logger.info(`Emitting ${SocketActions.USER_PROFILES_UPDATED}`) + this.emit(SocketActions.USER_PROFILES_UPDATED, payload) + }) + // ====== Local First Auth ====== socket.on( diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 5f0e6155ef..9212ba937e 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -6,4 +6,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(requestNotificationPermission) RCT_EXTERN_METHOD(checkNotificationPermission) RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys) +RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 09c7b62330..6614ceddcc 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -16,6 +16,7 @@ class CommunicationModule: RCTEventEmitter { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule") let keychainHandler = KeychainHandler() + let userMetadataHandler = UserMetadataHandler() @objc func sendDataPort(port: UInt16, socketIOSecret: String) { @@ -78,6 +79,29 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func saveUserMetadata(_ updatedMetadata: NSArray) { + let decoder = JSONDecoder() + var userMetadata: [UserMetadata] = [] + for metadataAsAny in updatedMetadata { + do { + let metadataAsString: String = metadataAsAny as! String + let data = Data(metadataAsString.utf8) + let decodedMetadata = try decoder.decode(UserMetadata.self, from: data) + CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))") + userMetadata.append(decodedMetadata) + } catch { + CommunicationModule.logger.error("Error while decoding user metadata: \(error)") + } + } + + do { + try self.userMetadataHandler.saveUserMetadata(updatedMetadata: userMetadata) + } catch { + CommunicationModule.logger.error("Error while saving user metadata: \(error)") + } + } + @objc func checkNotificationPermission() { UNUserNotificationCenter.current().getNotificationSettings { settings in diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index e1d226dc60..9260b8638c 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; }; 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; + 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; }; 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; }; 85137B01156B661E82184B11 /* libPods-Quiet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 287A03B12772D68AAF979A77 /* libPods-Quiet.a */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; @@ -620,6 +621,7 @@ 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; 287A03B12772D68AAF979A77 /* libPods-Quiet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Quiet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 2F63F73503B4A62481768267 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; }; 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; 677CCEF15F36760C73BC26A6 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; 70BDAC54548DB3505251D3DB /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; @@ -672,6 +674,7 @@ 13B07FAE1A68108700A75B9A /* Quiet */ = { isa = PBXGroup; children = ( + 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */, 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */, 180E120A2AEFB7F900804659 /* Utils.swift */, 18FD2A36296F009E00A2B8C0 /* AppDelegate.h */, @@ -5174,6 +5177,7 @@ 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */, 1889CA4E26E763E1004ECFBD /* Extensions.swift in Sources */, 1898143A2934CF70001F39E7 /* TorHandler.swift in Sources */, + 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */, 1868BCEE292E9212001D6D5E /* RNNodeJsMobile.m in Sources */, 1868C4382930D7D6001D6D5E /* DataDirectory.swift in Sources */, 1868C43E2930EAEA001D6D5E /* CommunicationBridge.m in Sources */, diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift new file mode 100644 index 0000000000..6e15a410c2 --- /dev/null +++ b/packages/mobile/ios/UserMetadataHandler.swift @@ -0,0 +1,24 @@ +// +// UserMetadataHandler.swift +// Quiet +// +// Created by Isla Koenigsknecht on 2/25/26. +// + +import CoreData +import OSLog + +public struct UserMetadata: Codable { + let userId: String + let nickname: String + let photo: String? +} + +@objc(UserMetadataHandler) +class UserMetadataHandler: NSObject { + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserMetadataHandler") + + public func saveUserMetadata(updatedMetadata: [UserMetadata]) throws -> Void { + UserMetadataHandler.logger.info("Storing user metadata") + } +} diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 6a83e68746..4c12d91f62 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -16,10 +16,11 @@ import { PayloadAction } from '@reduxjs/toolkit' import { socket as stateManager, Socket } from '@quiet/state-manager' import { initActions, WebsocketConnectionPayload } from '../init.slice' import { eventChannel } from 'redux-saga' -import { KeysUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types' +import { KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types' import { createLogger } from '../../../utils/logger' import { initSelectors } from '../init.selectors' import { keysActions } from '../../keys/keys.slice' +import { usersMetadataActions } from '../../userMetadata/usersMetadata.slice' const logger = createLogger('startConnection') @@ -80,6 +81,7 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect | ReturnType | ReturnType | ReturnType + | ReturnType >(emit => { socket.on('connect', async () => { socket_id = socket.id @@ -94,6 +96,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.info('Keys updated, writing to keychain') emit(keysActions.saveKeysInKeychain(payload)) }) + socket.on(SocketEvents.USER_PROFILES_UPDATED, async (payload: UserProfilesUpdatedPayload) => { + logger.info('User profiles updated, saving in ios native storage') + emit(usersMetadataActions.saveUserMetadataNatively(payload)) + }) return () => {} }) } diff --git a/packages/mobile/src/store/root.reducer.ts b/packages/mobile/src/store/root.reducer.ts index 0ffbbbb304..bfc6037d4d 100644 --- a/packages/mobile/src/store/root.reducer.ts +++ b/packages/mobile/src/store/root.reducer.ts @@ -6,6 +6,7 @@ import { navigationReducer } from './navigation/navigation.slice' import { nativeServicesReducer, nativeServicesActions } from './nativeServices/nativeServices.slice' import { pushNotificationsReducer } from './pushNotifications/pushNotifications.slice' import { keysReducer } from './keys/keys.slice' +import { usersMetadataReducer } from './userMetadata/usersMetadata.slice' export const reducers = { ...stateManagerReducers.reducers, @@ -14,6 +15,7 @@ export const reducers = { [StoreKeys.NativeServices]: nativeServicesReducer, [StoreKeys.PushNotifications]: pushNotificationsReducer, [StoreKeys.Keys]: keysReducer, + [StoreKeys.UsersMetadata]: usersMetadataReducer, } export const allReducers = combineReducers(reducers) diff --git a/packages/mobile/src/store/root.saga.ts b/packages/mobile/src/store/root.saga.ts index 98a149f6a6..5a818fbf6d 100644 --- a/packages/mobile/src/store/root.saga.ts +++ b/packages/mobile/src/store/root.saga.ts @@ -3,13 +3,14 @@ import { nativeServicesMasterSaga } from './nativeServices/nativeServices.master import { navigationMasterSaga } from './navigation/navigation.master.saga' import { initMasterSaga } from './init/init.master.saga' import { initActions } from './init/init.slice' -import { publicChannels } from '@quiet/state-manager' +import { publicChannels, Socket } from '@quiet/state-manager' import { showNotificationSaga } from './nativeServices/showNotification/showNotification.saga' import { clearReduxStore } from './nativeServices/leaveCommunity/leaveCommunity.saga' import { pushNotificationsMasterSaga } from './pushNotifications/pushNotifications.master.saga' import { setEngine, CryptoEngine } from 'pkijs' import { createLogger } from '../utils/logger' import { keysMasterSaga } from './keys/keys.master.saga' +import { usersMetadataMasterSaga } from './userMetadata/usersMetadata.master.saga' const logger = createLogger('root') @@ -56,6 +57,7 @@ function* storeReadySaga(): Generator { fork(nativeServicesMasterSaga), fork(pushNotificationsMasterSaga), fork(keysMasterSaga), + fork(usersMetadataMasterSaga), // Below line is reponsible for displaying notifications about messages from channels other than currently viewing one takeEvery(publicChannels.actions.markUnreadChannel.type, showNotificationSaga), takeLeading(initActions.canceledRootTask.type, clearReduxStore), diff --git a/packages/mobile/src/store/store.keys.ts b/packages/mobile/src/store/store.keys.ts index 9389713392..d015f270f8 100644 --- a/packages/mobile/src/store/store.keys.ts +++ b/packages/mobile/src/store/store.keys.ts @@ -4,4 +4,5 @@ export enum StoreKeys { NativeServices = 'NativeServices', PushNotifications = 'PushNotifications', Keys = 'Keys', + UsersMetadata = 'UsersMetadata', } diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts new file mode 100644 index 0000000000..577219221e --- /dev/null +++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts @@ -0,0 +1,18 @@ +import { type PayloadAction } from '@reduxjs/toolkit' +import { call } from 'typed-redux-saga' +import { NativeModules } from 'react-native' + +import { UserProfilesUpdatedPayload } from '@quiet/types' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('saveUserMetadataNativelySaga') + +export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator { + logger.info('Storing user metadata in ios native storage', action.payload.profiles) + try { + const updates: string[] = action.payload.profiles.map(profile => JSON.stringify(profile)) + yield* call(NativeModules.CommunicationModule.saveUserMetadata, updates) + } catch (e) { + logger.error('Error while updating user metadata in ios native storage', e) + } +} diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts b/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts new file mode 100644 index 0000000000..1be76b3594 --- /dev/null +++ b/packages/mobile/src/store/userMetadata/usersMetadata.master.saga.ts @@ -0,0 +1,19 @@ +import { takeEvery, cancelled } from 'redux-saga/effects' +import { all } from 'typed-redux-saga' +import { saveUserMetadataNativelySaga } from './saveUserMetadataNatively/saveUserMetadataNatively.saga' +import { createLogger } from '../../utils/logger' +import { usersMetadataActions } from './usersMetadata.slice' + +const logger = createLogger('usersMetadataMasterSaga') + +export function* usersMetadataMasterSaga(): Generator { + logger.info('usersMetadataMasterSaga starting') + try { + yield all([takeEvery(usersMetadataActions.saveUserMetadataNatively.type, saveUserMetadataNativelySaga)]) + } finally { + logger.info('usersMetadataMasterSaga stopping') + if (yield cancelled()) { + logger.info('usersMetadataMasterSaga cancelled') + } + } +} diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts b/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts new file mode 100644 index 0000000000..513827bdda --- /dev/null +++ b/packages/mobile/src/store/userMetadata/usersMetadata.selectors..ts @@ -0,0 +1,7 @@ +import { StoreKeys } from '../store.keys' +import { CreatedSelectors, StoreState } from '../store.types' + +const usersMetadataSlice: CreatedSelectors[StoreKeys.UsersMetadata] = (state: StoreState) => + state[StoreKeys.UsersMetadata] + +export const usersMetadataSelectors = {} diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts b/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts new file mode 100644 index 0000000000..7c415e8c50 --- /dev/null +++ b/packages/mobile/src/store/userMetadata/usersMetadata.slice.ts @@ -0,0 +1,19 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import { StoreKeys } from '../store.keys' +import { UserProfilesUpdatedPayload } from '@quiet/types' +import { createLogger } from '../../utils/logger' + +const logger = createLogger('keysSlice') + +export class UsersMetadataState {} + +export const usersMetadataSlice = createSlice({ + initialState: { ...new UsersMetadataState() }, + name: StoreKeys.Keys, + reducers: { + saveUserMetadataNatively: (state, _action: PayloadAction) => state, + }, +}) + +export const usersMetadataActions = usersMetadataSlice.actions +export const usersMetadataReducer = usersMetadataSlice.reducer diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts b/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts new file mode 100644 index 0000000000..8bb98ffe53 --- /dev/null +++ b/packages/mobile/src/store/userMetadata/usersMetadata.transform.ts @@ -0,0 +1,14 @@ +import { createTransform } from 'redux-persist' +import { StoreKeys } from '../store.keys' +import { UsersMetadataState } from './usersMetadata.slice' + +export const UsersMetadataTransform = createTransform( + (inboundState: UsersMetadataState, _key: any) => { + return inboundState + }, + (outboundState: UsersMetadataState, _key: any) => { + // TODO: determine if we still need this transform + return outboundState + }, + { whitelist: [StoreKeys.UsersMetadata] } +) diff --git a/packages/mobile/src/store/userMetadata/usersMetadata.types.ts b/packages/mobile/src/store/userMetadata/usersMetadata.types.ts new file mode 100644 index 0000000000..5148b443a9 --- /dev/null +++ b/packages/mobile/src/store/userMetadata/usersMetadata.types.ts @@ -0,0 +1,12 @@ +export type ExtendedKeyScope = { + type: string + name: string + generation: number + keyType: string +} + +export interface StorableKey { + scope: ExtendedKeyScope + key: string + teamId: string +} diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 4215fdf693..e988254ddf 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -41,6 +41,7 @@ import { HCaptchaRequest, HCaptchaChallengeRequest, InviteResultWithSalt, + UserProfilesUpdatedPayload, } from '@quiet/types' import { createLogger } from '../../../utils/logger' diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index 0b8cc40197..b46f698d33 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -1,15 +1,15 @@ import { PayloadAction } from '@reduxjs/toolkit' import { createLogger } from '../../../utils/logger' -import { put, select } from 'typed-redux-saga' +import { apply, call, put, select } from 'typed-redux-saga' import { userProfileSelectors } from './userProfile.selectors' -import { UserProfile } from '@quiet/types' -import { Socket } from '../../../types' +import { SocketActions, SocketEvents, SocketEventsMap, UserProfile } from '@quiet/types' +import { applyEmitParams, Socket } from '../../../types' import { usersActions } from '../users.slice' const logger = createLogger('updateUserProfilesSaga') export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator { - logger.info(`Updating user profiles (profile count = ${action.payload.length}`) + logger.info(`Updating user profiles (profile count = ${action.payload.length})`) const userProfiles = yield* select(userProfileSelectors.userProfiles) const updates = { ...userProfiles } for (const userProfile of action.payload) { @@ -46,7 +46,16 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction void> [SocketActions.LOAD_MIGRATION_DATA]: EmitEvent> + [SocketActions.USER_PROFILES_UPDATED]: EmitEvent // ====== Local First Auth ====== [SocketActions.VALIDATE_OR_CREATE_LONG_LIVED_LFA_INVITE]: EmitEvent< @@ -238,6 +242,7 @@ export interface SocketEventsMap { [SocketEvents.USERS_REMOVED]: EmitEvent [SocketEvents.USER_PROFILES_STORED]: EmitEvent [SocketEvents.KEYS_UPDATED]: EmitEvent + [SocketEvents.USER_PROFILES_UPDATED]: EmitEvent // ====== Files ====== [SocketEvents.FILE_ATTACHED]: EmitEvent diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 4319c7d3af..72197c39ac 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -59,6 +59,10 @@ export interface UserProfilesStoredEvent { profiles: UserProfile[] } +export interface UserProfilesUpdatedPayload { + profiles: UserProfile[] +} + export interface UsersUpdatedEvent { users: User[] } From 1497029d3223aba2fdec4875eb8721edcdc912a2 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:13:33 -0400 Subject: [PATCH 12/92] Persist user metadata in native storage --- packages/mobile/ios/CommunicationModule.swift | 13 +- packages/mobile/ios/KeychainHandler.swift | 2 +- packages/mobile/ios/UserMetadataHandler.swift | 173 +++++++++++++++++- .../saveUserMetadataNatively.saga.ts | 7 +- packages/state-manager/package-lock.json | 30 ++- packages/state-manager/package.json | 2 + .../userProfile/updateUserProfiles.saga.ts | 34 ++-- packages/types/src/user.ts | 3 +- 8 files changed, 233 insertions(+), 31 deletions(-) diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 6614ceddcc..1c25606062 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -82,12 +82,12 @@ class CommunicationModule: RCTEventEmitter { @objc func saveUserMetadata(_ updatedMetadata: NSArray) { let decoder = JSONDecoder() - var userMetadata: [UserMetadata] = [] + var userMetadata: [UserMetadataStruct] = [] for metadataAsAny in updatedMetadata { do { let metadataAsString: String = metadataAsAny as! String let data = Data(metadataAsString.utf8) - let decodedMetadata = try decoder.decode(UserMetadata.self, from: data) + let decodedMetadata = try decoder.decode(UserMetadataStruct.self, from: data) CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))") userMetadata.append(decodedMetadata) } catch { @@ -100,6 +100,15 @@ class CommunicationModule: RCTEventEmitter { } catch { CommunicationModule.logger.error("Error while saving user metadata: \(error)") } + + for metadata in userMetadata { + do { + let stored = try self.userMetadataHandler.fetchUserMetadataById(userId: metadata.userId) + CommunicationModule.logger.info("Passed: \(String(describing: metadata)), Stored: \(String(describing: stored?.toStruct()))") + } catch { + // do nothing + } + } } @objc diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index d478d84948..355365f045 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -5,7 +5,7 @@ // Created by Isla Koenigsknecht on 2/25/26. // - +import Foundation import CryptoKit import Security import CoreData diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift index 6e15a410c2..fad190915c 100644 --- a/packages/mobile/ios/UserMetadataHandler.swift +++ b/packages/mobile/ios/UserMetadataHandler.swift @@ -5,20 +5,185 @@ // Created by Isla Koenigsknecht on 2/25/26. // +import Foundation import CoreData import OSLog +import SwiftData -public struct UserMetadata: Codable { +public enum UserMetadataError: Error { + case missingModelContext + case noModelFound(id: String) + case incorrectModelCount(expected: Int, actual: Int) + case unhandledError(reason: Error) +} + +public struct ProfilePhotoStruct: Codable { + let ext: String + let path: String? + let size: Int + let width: Int + let height: Int +} + +public struct UserMetadataStruct: Codable { let userId: String let nickname: String - let photo: String? + let profilePhoto: ProfilePhotoStruct? +} + +@Model +class ProfilePhoto: Identifiable { + var id: String + var ext: String + var path: String? + var size: Int + var width: Int + var height: Int + var userMetadata: UserMetadata? + var createdAt: Date? + + init(id: String, ext: String, path: String?, size: Int, width: Int, height: Int, createdAt: Date?) { + self.id = id + self.ext = ext + self.path = path + self.size = size + self.width = width + self.height = height + self.createdAt = createdAt + } + + public func toStruct() -> ProfilePhotoStruct { + return ProfilePhotoStruct(ext: self.ext, path: self.path, size: self.size, width: self.width, height: self.height) + } +} + +@Model +class UserMetadata: Identifiable { + var id: String + var nickname: String + var createdAt: Date? = nil + + @Relationship(deleteRule: .cascade, inverse: \ProfilePhoto.userMetadata) + var profilePhoto: ProfilePhoto? + + init(id: String, nickname: String, profilePhoto: ProfilePhotoStruct?, createdAt: Date?) { + var profilePhotoModel: ProfilePhoto? = nil + if (profilePhoto != nil) { + profilePhotoModel = ProfilePhoto(id: id, ext: profilePhoto!.ext, path: profilePhoto!.path, size: profilePhoto!.size, width: profilePhoto!.width, height: profilePhoto!.height, createdAt: createdAt) + } + self.id = id + self.nickname = nickname + self.profilePhoto = profilePhotoModel + self.createdAt = createdAt + } + + public static func fromStruct(userMetadata: UserMetadataStruct, createdAt: Date?) -> UserMetadata { + return UserMetadata(id: userMetadata.userId, nickname: userMetadata.nickname, profilePhoto: userMetadata.profilePhoto, createdAt: createdAt) + } + + public func toStruct() -> UserMetadataStruct { + return UserMetadataStruct( + userId: self.id, + nickname: self.nickname, + profilePhoto: self.profilePhoto?.toStruct() + ) + } } @objc(UserMetadataHandler) class UserMetadataHandler: NSObject { private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserMetadataHandler") + private var container: ModelContainer? + private var modelContext: ModelContext? - public func saveUserMetadata(updatedMetadata: [UserMetadata]) throws -> Void { - UserMetadataHandler.logger.info("Storing user metadata") + public func initContainer() throws -> Void { + if (self.container != nil) { + UserMetadataHandler.logger.debug("Container already initialized, skipping...") + return + } + + do { + self.container = try ModelContainer(for: UserMetadata.self, configurations: .init(isStoredInMemoryOnly: false)) + self.modelContext = ModelContext(self.container!) + } catch { + UserMetadataHandler.logger.error("Error while initializing UserMetadata ModelContainer: \(error)") + throw error + } + } + + public func saveUserMetadata(updatedMetadata: [UserMetadataStruct]) throws -> Void { + do { + try self.initContainer() + } catch { + throw error + } + + guard let context = self.modelContext else { + throw UserMetadataError.missingModelContext + } + + UserMetadataHandler.logger.info("Inserting user metadata") + for metadata in updatedMetadata { + UserMetadataHandler.logger.debug("Inserting data: \(String(describing: metadata))") + let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now) + let found = try self.fetchUserMetadataById(userId: model.id) + if (found != nil) { + UserMetadataHandler.logger.debug("Replacing existing metadata for \(model.id)") + try self.deleteUserMetadata(model: model) + } + context.insert(model) + } + + UserMetadataHandler.logger.info("Persisting user metadata") + do { + try context.save() + } catch { + UserMetadataHandler.logger.error("Error while persisting UserMetadata model(s) to disk: \(error)") + throw KeychainHandlerError.unhandledError(reason: error) + } + } + + public func fetchUserMetadataById(userId: String) throws -> UserMetadata? { + UserMetadataHandler.logger.info("Fetching UserMetadata by ID: \(userId)") + + guard let context = self.modelContext else { + throw UserMetadataError.missingModelContext + } + + do { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == userId } + ) + let models = try context.fetch(descriptor) + guard models.count > 0 else { + return nil + } + guard models.count == 1 else { + for model in models { + UserMetadataHandler.logger.warning("Found: \(String(describing: model.toStruct()))") + } + throw UserMetadataError.incorrectModelCount(expected: 1, actual: models.count) + } + return models[0] + } catch { + UserMetadataHandler.logger.error("Error while fetching UserMetadata for ID \(userId): \(error)") + throw UserMetadataError.unhandledError(reason: error) + } + } + + public func deleteUserMetadata(model: UserMetadata) throws -> Void { + UserMetadataHandler.logger.info("Deleting UserMetadata record: \(String(describing: model.toStruct()))") + + guard let context = self.modelContext else { + throw UserMetadataError.missingModelContext + } + + do { + context.delete(model) + try context.save() + } catch { + UserMetadataHandler.logger.error("Error while deleting UserMetadata \(String(describing: model.toStruct())): \(error)") + throw UserMetadataError.unhandledError(reason: error) + } } } diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts index 577219221e..dba157954a 100644 --- a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts +++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts @@ -8,9 +8,12 @@ import { createLogger } from '../../../utils/logger' const logger = createLogger('saveUserMetadataNativelySaga') export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator { - logger.info('Storing user metadata in ios native storage', action.payload.profiles) + logger.info('Storing user metadata in ios native storage', action.payload.new, action.payload.updates) try { - const updates: string[] = action.payload.profiles.map(profile => JSON.stringify(profile)) + const updates: string[] = [ + ...action.payload.new.map(profile => JSON.stringify(profile)), + ...action.payload.updates.map(profile => JSON.stringify(profile)), + ] yield* call(NativeModules.CommunicationModule.saveUserMetadata, updates) } catch (e) { logger.error('Error while updating user metadata in ios native storage', e) diff --git a/packages/state-manager/package-lock.json b/packages/state-manager/package-lock.json index 190d9cfa2d..db39da7acc 100644 --- a/packages/state-manager/package-lock.json +++ b/packages/state-manager/package-lock.json @@ -14,6 +14,7 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", + "lodash": "^4.17.23", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", @@ -31,6 +32,7 @@ "@peculiar/webcrypto": "1.4.3", "@types/factory-girl": "^5.0.12", "@types/jest": "^26.0.24", + "@types/lodash": "^4.17.24", "@types/luxon": "^2.0.0", "@types/redux-saga": "^0.10.5", "babel-jest": "^29.3.1", @@ -4922,6 +4924,13 @@ "pretty-format": "^26.0.0" } }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/luxon": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz", @@ -11378,10 +11387,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.isequal": { "version": "4.5.0", @@ -16579,6 +16588,12 @@ "pretty-format": "^26.0.0" } }, + "@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true + }, "@types/luxon": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz", @@ -21516,10 +21531,9 @@ } }, "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" }, "lodash.isequal": { "version": "4.5.0", diff --git a/packages/state-manager/package.json b/packages/state-manager/package.json index a8279f49d1..6b0cb60265 100644 --- a/packages/state-manager/package.json +++ b/packages/state-manager/package.json @@ -33,6 +33,7 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", + "lodash": "^4.17.23", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", @@ -54,6 +55,7 @@ "@types/factory-girl": "^5.0.12", "@quiet/node-common": "^4.0.3", "@types/jest": "^26.0.24", + "@types/lodash": "^4.17.24", "@types/luxon": "^2.0.0", "@types/redux-saga": "^0.10.5", "babel-jest": "^29.3.1", diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index b46f698d33..b29a8e6abc 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -2,19 +2,24 @@ import { PayloadAction } from '@reduxjs/toolkit' import { createLogger } from '../../../utils/logger' import { apply, call, put, select } from 'typed-redux-saga' import { userProfileSelectors } from './userProfile.selectors' -import { SocketActions, SocketEvents, SocketEventsMap, UserProfile } from '@quiet/types' +import { SocketActions, SocketEvents, SocketEventsMap, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types' import { applyEmitParams, Socket } from '../../../types' import { usersActions } from '../users.slice' +import * as _ from 'lodash' const logger = createLogger('updateUserProfilesSaga') export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction): Generator { logger.info(`Updating user profiles (profile count = ${action.payload.length})`) - const userProfiles = yield* select(userProfileSelectors.userProfiles) - const updates = { ...userProfiles } + const existingProfiles = yield* select(userProfileSelectors.userProfiles) + const output: UserProfilesUpdatedPayload = { + new: [], + updates: [], + } + const updates = { ...existingProfiles } for (const userProfile of action.payload) { - if (updates[userProfile.userId]) { - const existingProfile = updates[userProfile.userId] + if (existingProfiles[userProfile.userId]) { + const existingProfile = existingProfiles[userProfile.userId] const updatedProfile = { ...existingProfile, @@ -42,20 +47,23 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction 0 || output.updates.length > 0) { + logger.info(`Emitting user profiles updated event`, output.new, output.updates) + yield* apply(socket, socket.emit, applyEmitParams(SocketActions.USER_PROFILES_UPDATED, output)) + } else { + logger.trace('Skipping user profile updated event, no new or updated profiles') + } logger.info(`Done`) } diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 72197c39ac..be8bd1da7b 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -60,7 +60,8 @@ export interface UserProfilesStoredEvent { } export interface UserProfilesUpdatedPayload { - profiles: UserProfile[] + new: UserProfile[] + updates: UserProfile[] } export interface UsersUpdatedEvent { From 35cb3c184248e26b7a58d81350b0df056b9b6e0f Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 13:27:06 -0400 Subject: [PATCH 13/92] Clean up logs and remove testing code --- packages/mobile/ios/CommunicationModule.swift | 10 ------- packages/mobile/ios/UserMetadataHandler.swift | 28 +++++++++++-------- .../saveUserMetadataNatively.saga.ts | 4 ++- .../userProfile/updateUserProfiles.saga.ts | 4 +-- 4 files changed, 22 insertions(+), 24 deletions(-) diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 1c25606062..b4eae33805 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -88,7 +88,6 @@ class CommunicationModule: RCTEventEmitter { let metadataAsString: String = metadataAsAny as! String let data = Data(metadataAsString.utf8) let decodedMetadata = try decoder.decode(UserMetadataStruct.self, from: data) - CommunicationModule.logger.info("Decoded user metadata: \(String(describing: decodedMetadata))") userMetadata.append(decodedMetadata) } catch { CommunicationModule.logger.error("Error while decoding user metadata: \(error)") @@ -100,15 +99,6 @@ class CommunicationModule: RCTEventEmitter { } catch { CommunicationModule.logger.error("Error while saving user metadata: \(error)") } - - for metadata in userMetadata { - do { - let stored = try self.userMetadataHandler.fetchUserMetadataById(userId: metadata.userId) - CommunicationModule.logger.info("Passed: \(String(describing: metadata)), Stored: \(String(describing: stored?.toStruct()))") - } catch { - // do nothing - } - } } @objc diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift index fad190915c..0f8141858d 100644 --- a/packages/mobile/ios/UserMetadataHandler.swift +++ b/packages/mobile/ios/UserMetadataHandler.swift @@ -52,6 +52,10 @@ class ProfilePhoto: Identifiable { self.createdAt = createdAt } + public static func fromStruct(id: String, profilePhoto: ProfilePhotoStruct, createdAt: Date?) -> ProfilePhoto { + return ProfilePhoto(id: id, ext: profilePhoto.ext, path: profilePhoto.path, size: profilePhoto.size, width: profilePhoto.width, height: profilePhoto.height, createdAt: createdAt) + } + public func toStruct() -> ProfilePhotoStruct { return ProfilePhotoStruct(ext: self.ext, path: self.path, size: self.size, width: self.width, height: self.height) } @@ -68,9 +72,10 @@ class UserMetadata: Identifiable { init(id: String, nickname: String, profilePhoto: ProfilePhotoStruct?, createdAt: Date?) { var profilePhotoModel: ProfilePhoto? = nil - if (profilePhoto != nil) { - profilePhotoModel = ProfilePhoto(id: id, ext: profilePhoto!.ext, path: profilePhoto!.path, size: profilePhoto!.size, width: profilePhoto!.width, height: profilePhoto!.height, createdAt: createdAt) + if let unwrappedProfilePhoto = profilePhoto { + profilePhotoModel = ProfilePhoto.fromStruct(id: id, profilePhoto: unwrappedProfilePhoto, createdAt: createdAt) } + self.id = id self.nickname = nickname self.profilePhoto = profilePhotoModel @@ -124,13 +129,13 @@ class UserMetadataHandler: NSObject { UserMetadataHandler.logger.info("Inserting user metadata") for metadata in updatedMetadata { - UserMetadataHandler.logger.debug("Inserting data: \(String(describing: metadata))") - let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now) - let found = try self.fetchUserMetadataById(userId: model.id) - if (found != nil) { - UserMetadataHandler.logger.debug("Replacing existing metadata for \(model.id)") - try self.deleteUserMetadata(model: model) + UserMetadataHandler.logger.debug("Inserting data for \(metadata.userId)") + let found = try self.fetchUserMetadataById(userId: metadata.userId) + if let unwrappedFound = found { + UserMetadataHandler.logger.debug("Replacing existing metadata for \(metadata.userId)") + try self.deleteUserMetadata(model: unwrappedFound) } + let model = UserMetadata.fromStruct(userMetadata: metadata, createdAt: Date.now) context.insert(model) } @@ -159,8 +164,9 @@ class UserMetadataHandler: NSObject { return nil } guard models.count == 1 else { + UserMetadataHandler.logger.warning("Found \(models.count) stored metadata records for \(userId)") for model in models { - UserMetadataHandler.logger.warning("Found: \(String(describing: model.toStruct()))") + UserMetadataHandler.logger.warning("Found \(userId) created at \(model.createdAt?.ISO8601Format() ?? "NO CREATEDAT")") } throw UserMetadataError.incorrectModelCount(expected: 1, actual: models.count) } @@ -172,7 +178,7 @@ class UserMetadataHandler: NSObject { } public func deleteUserMetadata(model: UserMetadata) throws -> Void { - UserMetadataHandler.logger.info("Deleting UserMetadata record: \(String(describing: model.toStruct()))") + UserMetadataHandler.logger.info("Deleting UserMetadata record for \(model.id)") guard let context = self.modelContext else { throw UserMetadataError.missingModelContext @@ -182,7 +188,7 @@ class UserMetadataHandler: NSObject { context.delete(model) try context.save() } catch { - UserMetadataHandler.logger.error("Error while deleting UserMetadata \(String(describing: model.toStruct())): \(error)") + UserMetadataHandler.logger.error("Error while deleting UserMetadata for \(model.id): \(error)") throw UserMetadataError.unhandledError(reason: error) } } diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts index dba157954a..f10b5b9123 100644 --- a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts +++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.ts @@ -8,7 +8,9 @@ import { createLogger } from '../../../utils/logger' const logger = createLogger('saveUserMetadataNativelySaga') export function* saveUserMetadataNativelySaga(action: PayloadAction): Generator { - logger.info('Storing user metadata in ios native storage', action.payload.new, action.payload.updates) + logger.info( + `Storing user metadata in ios native storage (new count = ${action.payload.new.length}, update count = ${action.payload.updates.length})` + ) try { const updates: string[] = [ ...action.payload.new.map(profile => JSON.stringify(profile)), diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index b29a8e6abc..1a143ecb0c 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -56,11 +56,11 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction 0 || output.updates.length > 0) { - logger.info(`Emitting user profiles updated event`, output.new, output.updates) + logger.debug(`Emitting user profiles updated event`) yield* apply(socket, socket.emit, applyEmitParams(SocketActions.USER_PROFILES_UPDATED, output)) } else { logger.trace('Skipping user profile updated event, no new or updated profiles') From 71896802a85e3f067bedd16efbeaa0a1d338059b Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:05:26 -0400 Subject: [PATCH 14/92] Fix tests --- packages/backend/src/nest/qss/qss.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index 7b9e2e451e..31e5dff651 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -1035,7 +1035,7 @@ describe('QSSService', () => { const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries').mockResolvedValue() // Trigger sigchain update which should process DLQ - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Wait for async processing await waitForExpect(async () => { @@ -1071,10 +1071,10 @@ describe('QSSService', () => { const processSpy = jest.spyOn(qssService, 'processDLQDecrypt') // Trigger first update - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Immediately trigger second update while first is processing - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) await waitForExpect(async () => { const remainingCount = await localDbService.getDLQDecryptCount(teamId) @@ -1097,7 +1097,7 @@ describe('QSSService', () => { const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries') // Trigger sigchain update - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Give it time to process await new Promise(resolve => setTimeout(resolve, 100)) From 7476ffce68ab8d1fa788a517f5c58ee944fd3e29 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:07:28 -0400 Subject: [PATCH 15/92] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78d6b4c930..5b0bd4db0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Use LFA-based identity in OrbitDB * Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) * Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) +* Store user metadata in IOS native storage for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) ### Fixes From fabb2a78b166ae06db10be510c5400add2f01cf5 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:15:26 -0400 Subject: [PATCH 16/92] Fix tests --- packages/backend/src/nest/qss/qss.service.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index 7b9e2e451e..31e5dff651 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -1035,7 +1035,7 @@ describe('QSSService', () => { const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries').mockResolvedValue() // Trigger sigchain update which should process DLQ - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Wait for async processing await waitForExpect(async () => { @@ -1071,10 +1071,10 @@ describe('QSSService', () => { const processSpy = jest.spyOn(qssService, 'processDLQDecrypt') // Trigger first update - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Immediately trigger second update while first is processing - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) await waitForExpect(async () => { const remainingCount = await localDbService.getDLQDecryptCount(teamId) @@ -1097,7 +1097,7 @@ describe('QSSService', () => { const ingestSpy = jest.spyOn(orbitDbService, 'ingestEntries') // Trigger sigchain update - sigchainService.emit('updated') + sigchainService.emit('updated', sigchainService.activeChainTeamName) // Give it time to process await new Promise(resolve => setTimeout(resolve, 100)) From edc9d9293c43f38f1d133049975b8b5b2c405faf Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:27:11 -0400 Subject: [PATCH 17/92] Update and move updateUserProfiles tests --- .../updateUserProfiles.saga.test.ts | 186 ++++++++++++++++++ .../src/sagas/users/users.slice.test.ts | 89 --------- .../src/utils/tests/factories.ts | 32 ++- packages/types/src/files.ts | 18 +- 4 files changed, 227 insertions(+), 98 deletions(-) create mode 100644 packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts delete mode 100644 packages/state-manager/src/sagas/users/users.slice.test.ts diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts new file mode 100644 index 0000000000..48d176d5be --- /dev/null +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts @@ -0,0 +1,186 @@ +import { expectSaga } from 'redux-saga-test-plan' +import { type FactoryGirl } from 'factory-girl' +import { UserProfile, SocketActions, Identity } from '@quiet/types' + +import { usersActions } from '../users.slice' +import { updateUserProfilesSaga } from './updateUserProfiles.saga' +import { MockedSocket } from '../../../utils/tests/mockedSocket' +import { Socket } from '../../../types' +import { Store } from '../../store.types' +import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' +import { getBaseTypesFactory } from '../../../utils/tests/factories' +import { combineReducers } from 'redux' +import { userProfileSelectors } from './userProfile.selectors' +import { createLogger } from '../../../utils/logger' + +describe('updateUserProfilesSaga', () => { + let store: Store + let baseTypesFactory: FactoryGirl + let socket: MockedSocket + let userProfile: UserProfile + let userId: string + + const logger = createLogger('updateUserProfilesSaga:test') + + beforeEach(async () => { + socket = new MockedSocket() + store = prepareStore().store + baseTypesFactory = await getBaseTypesFactory() + + userProfile = await baseTypesFactory.create('UserProfile') + userId = userProfile.userId + }) + + it('should clear profilePhoto.path if CID changes', async () => { + const newCid = 'new-cid' + + const existingProfiles = { + [userId]: userProfile, + } + + const updatedProfile: UserProfile = { + ...userProfile, + profilePhoto: { + ...userProfile.profilePhoto!, + cid: newCid, + path: null, + }, + } + + let userProfilesSelectCalls = 0 + + await expectSaga( + updateUserProfilesSaga, + socket as unknown as Socket, + // @ts-ignore + usersActions.updateUserProfiles([updatedProfile]) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + userProfilesSelectCalls += 1 + return existingProfiles + } + return next() + }, + }, + ]) + .apply.like({ + context: socket, + fn: socket.emit, + args: [ + SocketActions.USER_PROFILES_UPDATED, + { + new: [], + updates: [updatedProfile], + }, + ], + }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [updatedProfile], + }, + }) + .run() + }) + + it('should NOT clear profilePhoto.path if CID is the same', async () => { + const existingProfiles = { + [userId]: userProfile, + } + + const updatedProfile: UserProfile = { + ...userProfile, + profilePhoto: { + ...userProfile.profilePhoto!, + path: null, + }, + } + + let userProfilesSelectCalls = 0 + + await expectSaga( + updateUserProfilesSaga, + socket as unknown as Socket, + // @ts-ignore + usersActions.updateUserProfiles([updatedProfile]) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + userProfilesSelectCalls += 1 + return existingProfiles + } + return next() + }, + }, + ]) + // since we aren't updating the profile we aren't sending the socket event to ios + .not.apply.like({ + context: socket, + fn: socket.emit, + }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [userProfile], + }, + }) + .run() + }) + + it('should send new profile via socket to ios', async () => { + const existingProfiles = { + [userId]: userProfile, + } + + const newProfile = await baseTypesFactory.create('UserProfile') + + let userProfilesSelectCalls = 0 + + await expectSaga( + updateUserProfilesSaga, + socket as unknown as Socket, + // @ts-ignore + { payload: [userProfile, newProfile] } + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + userProfilesSelectCalls += 1 + return existingProfiles + } + return next() + }, + }, + ]) + .apply.like({ + context: socket, + fn: socket.emit, + args: [ + SocketActions.USER_PROFILES_UPDATED, + { + new: [newProfile], + updates: [], + }, + ], + }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [userProfile, newProfile], + }, + }) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/users/users.slice.test.ts b/packages/state-manager/src/sagas/users/users.slice.test.ts deleted file mode 100644 index 915ece4adf..0000000000 --- a/packages/state-manager/src/sagas/users/users.slice.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { usersSlice, UsersState } from './users.slice' -import { UserProfile, FileMetadata } from '@quiet/types' - -describe('usersSlice', () => { - describe('updateUserProfiles', () => { - it('should clear profilePhoto.path if CID changes', () => { - const userId = 'user-1' - const oldCid = 'old-cid' - const newCid = 'new-cid' - - const initialState: UsersState = { - userProfiles: { - [userId]: { - userId, - nickname: 'alice', - profilePhoto: { - cid: oldCid, - path: '/path/to/old/photo.png', - name: 'photo', - ext: '.png', - message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' }, - } as FileMetadata, - } as UserProfile, - }, - users: {}, - saveUserProfileError: null, - } - - const updatedProfile: UserProfile = { - userId, - nickname: 'alice', - profilePhoto: { - cid: newCid, - path: null, // Backend sends null path - name: 'photo', - ext: '.png', - message: { id: 'mid-2', channelId: 'PROFILE_PHOTO_CHANNEL_ID' }, - } as FileMetadata, - } - - const nextState = usersSlice.reducer(initialState, usersSlice.actions.updateUserProfiles([updatedProfile])) - - expect(nextState.userProfiles[userId].profilePhoto?.cid).toBe(newCid) - expect(nextState.userProfiles[userId].profilePhoto?.path).toBeNull() - }) - - it('should NOT clear profilePhoto.path if CID is the same', () => { - const userId = 'user-1' - const cid = 'same-cid' - const path = '/path/to/photo.png' - - const initialState: UsersState = { - userProfiles: { - [userId]: { - userId, - nickname: 'alice', - profilePhoto: { - cid, - path, - name: 'photo', - ext: '.png', - message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' }, - } as FileMetadata, - } as UserProfile, - }, - users: {}, - saveUserProfileError: null, - } - - const updatedProfile: UserProfile = { - userId, - nickname: 'alice-updated', - profilePhoto: { - cid, - path: null, // Backend might send null path even if we have it locally - name: 'photo', - ext: '.png', - message: { id: 'mid-1', channelId: 'PROFILE_PHOTO_CHANNEL_ID' }, - } as FileMetadata, - } - - const nextState = usersSlice.reducer(initialState, usersSlice.actions.updateUserProfiles([updatedProfile])) - - expect(nextState.userProfiles[userId].nickname).toBe('alice-updated') - expect(nextState.userProfiles[userId].profilePhoto?.cid).toBe(cid) - expect(nextState.userProfiles[userId].profilePhoto?.path).toBe(path) - }) - }) -}) diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 7659a6c395..57067a6a39 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -49,6 +49,8 @@ import { HCaptchaFormResponse, HCaptchaRequest, InviteResultWithSalt, + FileMessage, + FileEncryptionMetadata, } from '@quiet/types' import { InviteResult } from '@localfirst/auth' import { createLogger } from '../logger' @@ -66,6 +68,7 @@ import { errorsActions } from '../../sagas/errors/errors.slice' import { errorsSelectors } from '../../sagas/errors/errors.selectors' import { connectionActions } from '../../sagas/appConnection/connection.slice' import { connectionSelectors } from '../../sagas/appConnection/connection.selectors' +import { randomBytes } from 'crypto' const logger = createLogger('factories') @@ -125,11 +128,38 @@ export const getBaseTypesFactory = async () => { bio: factory.sequence('UserProfileDisplayData.bio', (n: number) => `bio_${n}`), }) + factory.define('FileMessage', Object, { + id: factory.sequence('FileMessage.id', (n: number) => `profile-photo-user-profile-photo-cid-${n}-${n}`), + channelId: '__profile-photo__', + }) + + factory.define('FileEncryptionMetadata', Object, { + header: factory.sequence('FileEncryptionMetadata.header', (n: number) => randomBytes(32).toString('base64')), + recipient: { + generation: 0, + type: 'ROLE', + name: 'MEMBER', + }, + }) + + factory.define('FileMetadata', Object, { + cid: factory.sequence('FileMetadata.cid', (n: number) => `user-profile-photo-cid-${n}`), + path: factory.sequence('FileMetadata.path', (n: number) => `/foo/bar/user-profile-photo-cid-${n}.png`), + ext: '.png', + name: factory.sequence('FileMetadata.name', (n: number) => `user-profile-photo-name-${n}`), + message: factory.assoc('FileMessage'), + size: factory.sequence('FileMetadata.size', (n: number) => 1024 + n), + width: factory.sequence('FileMetadata.width', (n: number) => 100 + n), + height: factory.sequence('FileMetadata.height', (n: number) => 100 + n), + enc: factory.assoc('FileEncryptionMetadata'), + }) + factory.define('UserProfile', Object, { userId: factory.sequence('UserProfile.userId', (n: number) => `userId_${n}`), nickname: factory.sequence('UserProfile.nickname', (n: number) => `userProfile.nickname_${n}`), - photo: 'dGVzdAo=', + photo: undefined, bio: factory.sequence('UserProfile.bio', (n: number) => `bio_${n}`), + profilePhoto: factory.assoc('FileMetadata'), }) factory.define('User', Object, { diff --git a/packages/types/src/files.ts b/packages/types/src/files.ts index 4997a9e7d5..cd7b823f27 100644 --- a/packages/types/src/files.ts +++ b/packages/types/src/files.ts @@ -11,20 +11,22 @@ export interface FilePreviewData { [id: string]: FileContent } +export interface FileEncryptionMetadata { + header: string + recipient: { + generation: number + type: string + name: string + } +} + export interface FileMetadata extends FileContent { cid: string message: FileMessage size?: number width?: number height?: number - enc?: { - header: string - recipient: { - generation: number - type: string - name: string - } - } + enc?: FileEncryptionMetadata } export interface AttachFilePayload { From 06bb47df0c555da5e81e63794092d6cf1527eea0 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:29:06 -0400 Subject: [PATCH 18/92] Fix factories test --- .../state-manager/src/utils/tests/factories.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 57067a6a39..74f5bd6f97 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -620,6 +620,21 @@ export const getSocketFactory = async () => { }, }) + factory.define(SocketActions.USER_PROFILES_UPDATED, Object, [ + { + profile: { + userId: 'user-id', + nickname: 'Test User', + photo: 'dGVzdAo=', + bio: 'This is a test user profile', + userData: { + onionAddress: 'test.onion', + peerId: 'peer-id', + }, + }, + }, + ]) + factory.define(`${SocketActions.SET_USER_PROFILE}_response`, Object, { success: true, error: undefined, From e7965b68dc8345c74189d802fbbc4c0922f966c0 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 16 Mar 2026 11:30:42 -0400 Subject: [PATCH 19/92] fix listener leak --- .../src/nest/auth/sigchain.service.spec.ts | 38 +++++++++++++++++++ .../backend/src/nest/auth/sigchain.service.ts | 17 ++++++--- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/nest/auth/sigchain.service.spec.ts b/packages/backend/src/nest/auth/sigchain.service.spec.ts index dd9f4730aa..5972869d83 100644 --- a/packages/backend/src/nest/auth/sigchain.service.spec.ts +++ b/packages/backend/src/nest/auth/sigchain.service.spec.ts @@ -6,6 +6,7 @@ import { LocalDbService } from '../local-db/local-db.service' import { LocalDbModule } from '../local-db/local-db.module' import { TestModule } from '../common/test.module' import { SigChainModule } from './sigchain.service.module' +import { SigChain } from './sigchain' const logger = createLogger('auth:sigchainManager.spec') @@ -91,3 +92,40 @@ describe('SigChainService', () => { expect(sigChainService.getChain({ teamName: TEAM_NAME2 })).toBeDefined() }) }) + +describe('SigChainService - listener lifecycle', () => { + let module: TestingModule + let sigChainService: SigChainService + let localDbService: LocalDbService + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [TestModule, SigChainModule, LocalDbModule], + }).compile() + sigChainService = await module.resolve(SigChainService) + localDbService = await module.resolve(LocalDbService) + await localDbService.open() + }) + + afterAll(async () => { + await localDbService.close() + await module.close() + }) + + it('does not accumulate listeners on chains when switching active chain', async () => { + const chainA: SigChain = await sigChainService.createChain('leakA', 'alice', true) + // chainA is active: one listener attached + expect(chainA.listenerCount('updated')).toBe(1) + + const chainB: SigChain = await sigChainService.createChain('leakB', 'bob', true) + // Active switched A → B. detachSocketListeners(A) must have removed A's listener. + expect(chainA.listenerCount('updated')).toBe(0) + expect(chainB.listenerCount('updated')).toBe(1) + + sigChainService.setActiveChain('leakA') + // Active switched B → A. detachSocketListeners(B) must have removed B's listener, + // and attachSocketListeners(A) adds exactly one to A. + expect(chainA.listenerCount('updated')).toBe(1) + expect(chainB.listenerCount('updated')).toBe(0) + }) +}) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index cdaacd35ee..cfff887d84 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -32,6 +32,7 @@ export class SigChainService extends EventEmitter { private readonly logger = createLogger(SigChainService.name) private chains: Map = new Map() public connections: Map = new Map() + private readonly _chainListeners: Map void> = new Map() constructor( @Inject(SERVER_IO_PROVIDER) public readonly serverIoProvider: ServerIoProviderTypes, @@ -243,18 +244,20 @@ export class SigChainService extends EventEmitter { private attachSocketListeners(chain: SigChain): void { this.logger.info('Attaching socket listeners') - const _onTeamUpdate = (): void => { + const listener = (): void => { this.handleChainUpdate(chain.team!.teamName) } - chain.on('updated', _onTeamUpdate) + this._chainListeners.set(chain, listener) + chain.on('updated', listener) } private detachSocketListeners(chain: SigChain): void { this.logger.info('Detaching socket listeners') - const _onTeamUpdate = (): void => { - this.handleChainUpdate(chain.team!.teamName) + const listener = this._chainListeners.get(chain) + if (listener) { + chain.removeListener('updated', listener) + this._chainListeners.delete(chain) } - chain.removeListener('updated', _onTeamUpdate) } /** @@ -285,6 +288,10 @@ export class SigChainService extends EventEmitter { * @param fromDisk Whether to delete the chain from disk as well */ async deleteChain(teamName: string, fromDisk: boolean): Promise { + const chain = this.chains.get(teamName) + if (chain) { + this.detachSocketListeners(chain) + } if (fromDisk) { this.localDbService.deleteSigChain(teamName) } From 461342d21711e2d588153a01ade63edc3b2e8a00 Mon Sep 17 00:00:00 2001 From: Isla <5048549+islathehut@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:49:32 -0400 Subject: [PATCH 20/92] Fix state manager stuff --- packages/state-manager/src/utils/tests/factories.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 74f5bd6f97..11fea428ba 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -51,6 +51,7 @@ import { InviteResultWithSalt, FileMessage, FileEncryptionMetadata, + UserProfilesUpdatedPayload, } from '@quiet/types' import { InviteResult } from '@localfirst/auth' import { createLogger } from '../logger' @@ -620,9 +621,9 @@ export const getSocketFactory = async () => { }, }) - factory.define(SocketActions.USER_PROFILES_UPDATED, Object, [ - { - profile: { + factory.define(SocketActions.USER_PROFILES_UPDATED, Object, { + new: [ + { userId: 'user-id', nickname: 'Test User', photo: 'dGVzdAo=', @@ -632,8 +633,9 @@ export const getSocketFactory = async () => { peerId: 'peer-id', }, }, - }, - ]) + ], + updates: [], + }) factory.define(`${SocketActions.SET_USER_PROFILE}_response`, Object, { success: true, From 0350734d9d6598da7644df76db2c8dc5079c3d1b Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 23 Mar 2026 16:49:34 -0400 Subject: [PATCH 21/92] add a default title and body so that message is not treated as silent --- packages/desktop/.env.development | 1 + packages/desktop/src/main/main.ts | 1 + packages/mobile/ios/CommunicationModule.swift | 14 ++++++++++++++ 3 files changed, 16 insertions(+) diff --git a/packages/desktop/.env.development b/packages/desktop/.env.development index 69254342b9..31c1255c0f 100644 --- a/packages/desktop/.env.development +++ b/packages/desktop/.env.development @@ -2,6 +2,7 @@ COLORIZE=true QSS_ALLOWED=true QPS_ALLOWED=true +QPS_ENABLED=true QSS_ENDPOINT=ws://127.0.0.1:3003 LOG_TO_FILE=true ENVFILE=.env.development diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 196dae3ac1..2f7a11a9a9 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -586,6 +586,7 @@ app.on('ready', async () => { STATIC_LOG_ID: process.env.STATIC_LOG_ID, QSS_ALLOWED: process.env.QSS_ALLOWED ?? 'false', QSS_ENDPOINT: process.env.QSS_ENDPOINT, + QPS_ALLOWED: process.env.QPS_ALLOWED ?? 'false', HCAPTCHA_TEMPLATE_PATH: path.join(__dirname, 'captcha.html'), HCAPTCHA_FORWARD_ENDPOINT: process.env.HCAPTCHA_FORWARD_ENDPOINT, IS_E2E: process.env.IS_E2E ?? 'false', diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index f6961cdbfe..816228d2d1 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -12,6 +12,8 @@ class CommunicationModule: RCTEventEmitter { static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_" + + private var hasListeners = false @objc func sendDataPort(port: UInt16, socketIOSecret: String) { @@ -85,9 +87,21 @@ class CommunicationModule: RCTEventEmitter { @objc func sendDeviceToken(_ token: String) { + guard hasListeners else { + NSLog("Skipping deviceTokenReceived emit because no JS listeners are attached; JS will fetch the current FCM token when ready.") + return + } self.sendEvent(withName: CommunicationModule.DEVICE_TOKEN_RECEIVED, body: ["token": token]) } + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + override func supportedEvents() -> [String]! { return [ CommunicationModule.BACKEND_EVENT_IDENTIFIER, From f05c0860cd050a7f4846b07ab54e2ac3d6702e08 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 23 Mar 2026 16:59:06 -0400 Subject: [PATCH 22/92] fix fcm sending token before js sets up --- .../ios/Quiet/FirebaseMessagingModule.m | 7 +-- .../ios/Quiet/FirebaseMessagingModule.swift | 24 +------- packages/mobile/src/setupTests.tsx | 3 + .../pushNotifications.master.saga.ts | 59 ++++++++++++++++--- 4 files changed, 57 insertions(+), 36 deletions(-) diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m index 3afff6151f..d365441814 100644 --- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m +++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m @@ -1,7 +1,6 @@ #import -#import -@interface RCT_EXTERN_MODULE(FirebaseMessagingModule, RCTEventEmitter) +@interface RCT_EXTERN_MODULE(FirebaseMessagingModule, NSObject) RCT_EXTERN_METHOD(getToken:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) @@ -19,8 +18,4 @@ @interface RCT_EXTERN_MODULE(FirebaseMessagingModule, RCTEventEmitter) RCT_EXTERN_METHOD(setEncryptionKey:(NSString *)key) -RCT_EXTERN_METHOD(onTokenReceived:(NSString *)token) - -RCT_EXTERN_METHOD(onTokenRefreshed:(NSString *)token) - @end diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift index 38c4e66f61..adcaeb6174 100644 --- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift +++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift @@ -3,22 +3,12 @@ import React import FirebaseMessaging @objc(FirebaseMessagingModule) -class FirebaseMessagingModule: RCTEventEmitter { +class FirebaseMessagingModule: NSObject { - static let FCM_TOKEN_RECEIVED = "fcmTokenReceived" - static let FCM_TOKEN_REFRESHED = "fcmTokenRefreshed" - - override static func requiresMainQueueSetup() -> Bool { + @objc static func requiresMainQueueSetup() -> Bool { return true } - override func supportedEvents() -> [String]! { - return [ - FirebaseMessagingModule.FCM_TOKEN_RECEIVED, - FirebaseMessagingModule.FCM_TOKEN_REFRESHED - ] - } - @objc func getToken(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { Messaging.messaging().token { token, error in @@ -68,14 +58,4 @@ class FirebaseMessagingModule: RCTEventEmitter { @objc func setEncryptionKey(_ key: String) { } - - @objc - func onTokenReceived(_ token: String) { - self.sendEvent(withName: FirebaseMessagingModule.FCM_TOKEN_RECEIVED, body: ["token": token]) - } - - @objc - func onTokenRefreshed(_ token: String) { - self.sendEvent(withName: FirebaseMessagingModule.FCM_TOKEN_REFRESHED, body: ["token": token]) - } } diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index 729f2fc908..c668908f41 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -48,6 +48,9 @@ jest.mock('react-native', () => { checkNotificationPermission: jest.fn(), handleIncomingEvents: jest.fn(), } + rn.NativeModules.FirebaseMessagingModule = { + getToken: jest.fn(), + } return rn }) diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts index e29f7fcb60..d6ea1539cb 100644 --- a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts +++ b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts @@ -8,6 +8,7 @@ import { handlePermissionResultSaga, PermissionResultPayload, } from './handlePermissionResult/handlePermissionResult.saga' +import { NotificationPermissionStatus } from './pushNotifications.types' import { pushNotifications } from '@quiet/state-manager' import { initSelectors } from '../init/init.selectors' import { createLogger } from '../../utils/logger' @@ -18,6 +19,8 @@ const logger = createLogger('pushNotificationsMasterSaga') const NOTIFICATION_PERMISSION_RESULT = 'notificationPermissionResult' const DEVICE_TOKEN_RECEIVED = 'deviceTokenReceived' +const firebaseMessagingModule = NativeModules.FirebaseMessagingModule + function* requestPermissionSaga(): Generator { logger.info('Requesting iOS notification permission') yield* call(NativeModules.CommunicationModule.requestNotificationPermission) @@ -53,12 +56,59 @@ function createDeviceTokenChannel() { }) } +function hasGrantedPermission(payload: PermissionResultPayload): boolean { + if (payload.status) { + return payload.status === NotificationPermissionStatus.Granted + } + + return payload.granted === true +} + +function* waitForWebsocketConnectionSaga(): Generator { + while (true) { + const connected = yield* select(initSelectors.isWebsocketConnected) + if (connected) break + yield* delay(500) + } +} + +function* sendDeviceTokenToBackendSaga(token: string): Generator { + logger.info('Waiting for websocket connection before sending FCM token') + yield* call(waitForWebsocketConnectionSaga) + logger.info('Sending FCM token to backend') + yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token)) +} + +function* syncCurrentDeviceTokenSaga(): Generator { + if (!firebaseMessagingModule?.getToken) { + logger.warn('FirebaseMessagingModule.getToken is unavailable, skipping initial token sync') + return + } + + try { + const token = (yield* call([firebaseMessagingModule, firebaseMessagingModule.getToken])) as string | null + + if (!token) { + logger.info('No current FCM token available yet') + return + } + + logger.info('Fetched current FCM token from native module') + yield* call(sendDeviceTokenToBackendSaga, token) + } catch (error) { + logger.error('Failed to fetch current FCM token', error) + } +} + function* watchPermissionResults(): Generator { const channel = yield* call(createPermissionResultChannel) try { while (true) { const payload = yield* take(channel) yield* call(handlePermissionResultSaga, payload) + if (hasGrantedPermission(payload)) { + yield* call(syncCurrentDeviceTokenSaga) + } } } finally { if (yield cancelled()) { @@ -96,14 +146,7 @@ function* watchDeviceToken(): Generator { try { while (true) { const { token } = yield* take(channel) - logger.info('Received device token, waiting for websocket connection') - while (true) { - const connected = yield* select(initSelectors.isWebsocketConnected) - if (connected) break - yield* delay(500) - } - logger.info('Sending device token to backend') - yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token)) + yield* call(sendDeviceTokenToBackendSaga, token) } } finally { if (yield cancelled()) { From 55129648612b51607f1ba625f7d053e9741d2468 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 23 Mar 2026 20:32:32 -0400 Subject: [PATCH 23/92] fix first join token flush --- .../backend/src/nest/qps/qps.service.spec.ts | 34 +++++++++++++------ packages/backend/src/nest/qps/qps.service.ts | 27 ++++++++++++++- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index 76959ccf41..9f73d0d4eb 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -24,6 +24,13 @@ class MockSigChainService extends EventEmitter { } } +class MockQSSService extends EventEmitter { + on = this.addListener + emitEvent(event: QSSEvents, payload?: any) { + this.emit(event, payload) + } +} + class MockSocketService extends EventEmitter {} class MockNotificationTokensStore { @@ -34,6 +41,7 @@ class MockNotificationTokensStore { describe('QPSService', () => { let qpsService: QPSService let qssClient: MockQSSClient + let qssService: MockQSSService let sigChainService: MockSigChainService let socketService: MockSocketService let notificationTokensStore: MockNotificationTokensStore @@ -64,6 +72,7 @@ describe('QPSService', () => { beforeEach(() => { jest.clearAllMocks() qssClient = new MockQSSClient() + qssService = new MockQSSService() sigChainService = new MockSigChainService() socketService = new MockSocketService() notificationTokensStore = new MockNotificationTokensStore() @@ -72,6 +81,7 @@ describe('QPSService', () => { true, // qpsAllowed socketService as any, qssClient as any, + qssService as any, sigChainService as any, notificationTokensStore as any ) @@ -123,6 +133,7 @@ describe('QPSService', () => { false, socketService as any, qssClient as any, + qssService as any, sigChainService as any, notificationTokensStore as any ) @@ -166,7 +177,7 @@ describe('QPSService', () => { // Now become ready and flush setReady() - qssClient.emit(QSSEvents.QSS_CONNECTED) + qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) // Wait for async flush await new Promise(resolve => setTimeout(resolve, 10)) @@ -219,33 +230,33 @@ describe('QPSService', () => { }) }) - describe('flush on sigchain updated', () => { - it('flushes cached token when sigchain joins and QSS is connected', async () => { + describe('flush on QSS_FULLY_JOINED', () => { + it('flushes cached token when QSS fully joins and QSS is connected', async () => { // Cache token: QSS connected but no sigchain qssClient.connected = true sigChainService.activeChain = null await qpsService.register(TOKEN) expect(qssClient.sendMessage).not.toHaveBeenCalled() - // Sigchain joins + // QSS completes the join flow and the sigchain is now ready setReady() - sigChainService.emit('updated') + qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) await new Promise(resolve => setTimeout(resolve, 10)) expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) }) - it('does not flush when sigchain updates but QSS is not connected', async () => { + it('does not flush when QSS fully joins but QSS is not connected', async () => { qssClient.connected = false sigChainService.activeChain = null await qpsService.register(TOKEN) - // Sigchain joins but QSS still disconnected + // Join completes but the transport is still disconnected sigChainService.activeChain = { team: { id: TEAM_ID }, roles: { amIMemberOfRole: () => true }, } - sigChainService.emit('updated') + qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) await new Promise(resolve => setTimeout(resolve, 10)) expect(qssClient.sendMessage).not.toHaveBeenCalled() @@ -259,13 +270,13 @@ describe('QPSService', () => { await qpsService.register(TOKEN) setReady() - qssClient.emit(QSSEvents.QSS_CONNECTED) + qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) await new Promise(resolve => setTimeout(resolve, 10)) expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) - // Second event should not trigger another send - sigChainService.emit('updated') + // Second fully-joined event should not trigger another send + qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) await new Promise(resolve => setTimeout(resolve, 10)) expect(qssClient.sendMessage).toHaveBeenCalledTimes(1) @@ -302,6 +313,7 @@ describe('QPSService', () => { false, socketService as any, qssClient as any, + qssService as any, sigChainService as any, notificationTokensStore as any ) diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts index b41f16ff0b..071e6d15c0 100644 --- a/packages/backend/src/nest/qps/qps.service.ts +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -16,6 +16,7 @@ import { import { SigChainService } from '../auth/sigchain.service' import { RoleName } from '../auth/services/roles/roles' import { NotificationTokensStore } from '../storage/notifications/notificationTokens.store' +import { QSSService } from '../qss/qss.service' const BUNDLE_ID = 'com.quietmobile' const PUSH_BATCH_SIZE = 500 // FCM allows up to 500 tokens per batch request @@ -29,6 +30,7 @@ export class QPSService implements OnModuleInit { @Inject(QPS_ALLOWED) private readonly qpsAllowed: boolean, private readonly socketService: SocketService, private readonly qssClient: QSSClient, + private readonly qssService: QSSService, private readonly sigChainService: SigChainService, private readonly notificationTokensStore: NotificationTokensStore ) {} @@ -47,6 +49,7 @@ export class QPSService implements OnModuleInit { await this.register(payload.deviceToken) }) + this.qssService.on(QSSEvents.QSS_FULLY_JOINED, () => this._flushPendingToken()) this.qssClient.on(QSSEvents.QSS_CONNECTED, () => this._flushPendingToken()) this.qssClient.on(QSSEvents.QSS_LOG_SYNCED, (teamId: string) => void this.sendBatchPush(teamId)) this.sigChainService.on('updated', () => this._flushPendingToken()) @@ -73,7 +76,29 @@ export class QPSService implements OnModuleInit { } private async _flushPendingToken(): Promise { - if (this._pendingDeviceToken == undefined || !this.ready) { + this.logger.debug('Checking if pending device token can be flushed') + const hasPendingToken = this._pendingDeviceToken != undefined + const qssConnected = this.qssClient.connected + const hasMemberKey = this._hasMemberKey() + + if (!hasPendingToken || !qssConnected || !hasMemberKey) { + const reasons: string[] = [] + if (!hasPendingToken) { + reasons.push('no pending device token') + } + if (!qssConnected) { + reasons.push('QSS not connected') + } + if (!hasMemberKey) { + reasons.push('sigchain member key unavailable') + } + + this.logger.debug(`Skipping cached device token flush: ${reasons.join(', ')}`) + return + } + + if (!this._pendingDeviceToken) { + this.logger.warn('No pending device token found during flush') return } From f90554e8c0bc1447c82f7938cf0165ad1b0e572f Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 14:29:32 -0400 Subject: [PATCH 24/92] merge nse-test: NSE business logic + orbitDb helper - Replace stub NotificationService with full fetch/auth/badge impl - Add NSEAuthService, NSECryptoService, NSEKeychainHelper, NSEModels, NSENetworkClient - Add getTeamLogEntriesSince to OrbitDbService Co-Authored-By: Claude Sonnet 4.6 --- .../nest/storage/orbitDb/orbitDb.service.ts | 23 +++ .../NSEAuthService.swift | 107 ++++++++++++++ .../NSECryptoService.swift | 139 ++++++++++++++++++ .../NSEKeychainHelper.swift | 82 +++++++++++ .../NSEModels.swift | 94 ++++++++++++ .../NSENetworkClient.swift | 111 ++++++++++++++ .../NotificationService.swift | 98 +++++++++--- 7 files changed, 632 insertions(+), 22 deletions(-) create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 3b0bf8b44d..e26aaff46b 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -304,6 +304,29 @@ export class OrbitDbService { } } + /** + * Returns all EncryptedMessage entries for a given teamId where createdAt >= since. + * Used by the NSE auth endpoint. + */ + public async getTeamLogEntriesSince(teamId: string, since: number): Promise { + if (this.orbitDbInstance == undefined) { + throw new Error('OrbitDB instance is not initialized. Call create() first.') + } + + const results: any[] = [] + for (const store of Object.values(this.stores)) { + if (store.meta?.['teamId'] !== teamId) continue + for await (const entry of (store.log as LogType).iterator()) { + const value = (entry as any).payload?.value + if (value && typeof value.createdAt === 'number' && value.createdAt >= since) { + results.push(value) + } + } + } + + return results + } + public static updateMetadata(db: DatabaseType, metadata: Record): void { db.meta = { ...(db.meta ?? {}), diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift new file mode 100644 index 0000000000..5ab93d9b64 --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift @@ -0,0 +1,107 @@ +import Foundation + +class NSEAuthService { + private let client: NSENetworkClient + private let crypto: DeviceCryptography + + private var tokenCache: [String: (token: String, expiry: Date)] = [:] + + init(client: NSENetworkClient, crypto: DeviceCryptography) { + self.client = client + self.crypto = crypto + } + + // MARK: - Full auth flow + + func authenticate(deviceId: String, teamId: String) async throws -> String { + if let cached = tokenCache[teamId], cached.expiry > Date() { + return cached.token + } + + let challengeResp = try await client.requestChallenge(deviceId: deviceId, teamId: teamId) + + // Sign the challenge using msgpack serialization (matching TypeScript identity.prove()) + let privateKeyData = try NSEKeychainHelper.getDevicePrivateKey(deviceId: deviceId) + let proof = try crypto.signChallengePayload(challengeResp.challenge, privateKeyData: privateKeyData) + + let tokenResp = try await client.requestToken( + challengeId: challengeResp.challengeId, + deviceId: deviceId, + proof: proof + ) + + tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30)) + + return tokenResp.token + } + + // MARK: - Fetch log entries + + func fetchNewEntries(teamId: String, since: Int64) async throws -> [LogEntry] { + let deviceId = try NSEKeychainHelper.getDeviceId() + let token = try await authenticate(deviceId: deviceId, teamId: teamId) + let resp = try await client.fetchLogEntries(teamId: teamId, since: since, token: token) + return resp.entries + } +} + +// MARK: - Base58 encoder (Bitcoin alphabet) + +enum Base58 { + static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func encode(_ data: Data) -> String { + let bytes = [UInt8](data) + var leadingZeros = 0 + for b in bytes { + if b == 0 { leadingZeros += 1 } else { break } + } + + var result = [UInt8]() + for byte in bytes { + var carry = Int(byte) + for i in 0.. 0 { + result.append(UInt8(carry % 58)) + carry /= 58 + } + } + + let leading = String(repeating: "1", count: leadingZeros) + let encoded = result.reversed().map { alphabet[Int($0)] } + return leading + String(encoded) + } + + static func decode(_ string: String) -> Data? { + let alphabetMap: [Character: Int] = Dictionary( + uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) } + ) + + var leadingZeros = 0 + for c in string { + if c == "1" { leadingZeros += 1 } else { break } + } + + var result = [UInt8]() + for c in string { + guard let digit = alphabetMap[c] else { return nil } + var carry = digit + for i in 0..>= 8 + } + while carry > 0 { + result.append(UInt8(carry & 0xFF)) + carry >>= 8 + } + } + + let leading = [UInt8](repeating: 0, count: leadingZeros) + return Data(leading + result.reversed()) + } +} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift new file mode 100644 index 0000000000..5bfc26425c --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -0,0 +1,139 @@ +import CryptoKit +import Foundation + +// MARK: - Protocol + +protocol DeviceCryptography { + /// Sign message bytes with the device Ed25519 private key. + /// Returns raw 64-byte signature. + func sign(message: Data) throws -> Data + + /// Signs a challenge payload exactly as `identity.prove()` does in TypeScript. + func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload +} + +extension DeviceCryptography { + func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload { + let payloadBytes = try NSEMsgpack.encode(challenge) + let signatureBytes = try signBytes(payloadBytes, privateKeyData: privateKeyData) + return ProofPayload(signature: Base58.encode(signatureBytes)) + } + + func signBytes(_ message: Data, privateKeyData: Data) throws -> Data { + guard privateKeyData.count == 64 || privateKeyData.count == 32 else { + throw NSECryptoError.invalidKeyLength(expected: 64, got: privateKeyData.count) + } + let seed = privateKeyData.prefix(32) + let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed) + return try privateKey.signature(for: message) + } +} + +// MARK: - Errors + +enum NSECryptoError: Error, LocalizedError { + case invalidKeyLength(expected: Int, got: Int) + case invalidBase58 + case signingFailed + + var errorDescription: String? { + switch self { + case .invalidKeyLength(let e, let g): return "Invalid key length: expected \(e), got \(g)" + case .invalidBase58: return "Invalid base58 encoding" + case .signingFailed: return "Signing failed" + } + } +} + +// MARK: - NSECryptoService + +/// Mirrors `@localfirst/crypto` signing used in `identity.prove()`: +/// 1. msgpack-serialize the challenge object (same encoding as msgpackr) +/// 2. Sign with `crypto_sign_detached` (Ed25519, libsodium 64-byte key) +/// 3. base58-encode the 64-byte signature +class NSECryptoService: DeviceCryptography { + + /// Signs raw bytes using the device Ed25519 private key. + /// The 64-byte libsodium secret key = 32-byte seed ++ 32-byte public key. + /// CryptoKit only needs the 32-byte seed. + func sign(message: Data) throws -> Data { + // Requires private key externally; use signBytes(_:privateKeyData:) directly. + throw NSECryptoError.signingFailed + } +} + +// MARK: - Msgpack encoder +// Encodes the ChallengePayload object in the same format as msgpackr.pack(): +// { type: string, name: string, nonce: string, timestamp: number } +// Field order must exactly match the JS object insertion order. + +enum NSEMsgpack { + enum MsgpackError: Error { case unsupportedType, stringTooLong } + + /// Encodes a ChallengePayload in the same byte format as msgpackr.pack(). + static func encode(_ challenge: ChallengePayload) throws -> Data { + var out = Data() + // fixmap with 4 elements: 0x84 + out.append(0x84) + // key: "type" value: challenge.type + try appendString("type", to: &out) + try appendString(challenge.type, to: &out) + // key: "name" value: challenge.name + try appendString("name", to: &out) + try appendString(challenge.name, to: &out) + // key: "nonce" value: challenge.nonce + try appendString("nonce", to: &out) + try appendString(challenge.nonce, to: &out) + // key: "timestamp" value: challenge.timestamp (Unix ms, Int64) + try appendString("timestamp", to: &out) + appendUInt64(UInt64(bitPattern: Int64(challenge.timestamp)), to: &out) + return out + } + + private static func appendString(_ s: String, to out: inout Data) throws { + guard let bytes = s.data(using: .utf8) else { throw MsgpackError.unsupportedType } + let len = bytes.count + if len < 32 { + // fixstr: 0xa0 | len + out.append(UInt8(0xa0 | len)) + } else if len <= 0xFF { + // str8: 0xd9, len + out.append(0xd9) + out.append(UInt8(len)) + } else if len <= 0xFFFF { + // str16: 0xda, len_hi, len_lo + out.append(0xda) + out.append(UInt8((len >> 8) & 0xFF)) + out.append(UInt8(len & 0xFF)) + } else { + throw MsgpackError.stringTooLong + } + out.append(contentsOf: bytes) + } + + /// Encodes a positive integer as uint64 (0xcf + 8 bytes BE). + /// msgpackr uses uint64 for integers that exceed 2^32. + private static func appendUInt64(_ v: UInt64, to out: inout Data) { + if v <= 0x7F { + out.append(UInt8(v)) // positive fixint + } else if v <= 0xFF { + out.append(0xcc); out.append(UInt8(v)) // uint8 + } else if v <= 0xFFFF { + out.append(0xcd) + out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8(v & 0xFF)) + } else if v <= 0xFFFFFFFF { + out.append(0xce) + out.append(UInt8((v >> 24) & 0xFF)) + out.append(UInt8((v >> 16) & 0xFF)) + out.append(UInt8((v >> 8) & 0xFF)) + out.append(UInt8(v & 0xFF)) + } else { + // uint64: 0xcf + 8 bytes big-endian (used for Date.now() timestamps) + out.append(0xcf) + for shift in stride(from: 56, through: 0, by: -8) { + out.append(UInt8((v >> shift) & 0xFF)) + } + } + } +} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift new file mode 100644 index 0000000000..16d3dfd2e4 --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -0,0 +1,82 @@ +import Foundation +import Security + +struct NSEKeychainHelper { + + // MARK: - Key names (must match what the main app stores) + + private static let devicePrivateKeyPrefix = "quiet.device.privateKey." + private static let deviceIdKey = "quiet.device.id" + private static let teamIdKey = "quiet.team.id" + private static let lastSyncKey = "quiet.nse.lastSyncTimestamp" + + // MARK: - Device private key + + static func getDevicePrivateKey(deviceId: String) throws -> Data { + let account = devicePrivateKeyPrefix + deviceId + return try readData(account: account, label: "device private key") + } + + // MARK: - Device ID + + static func getDeviceId() throws -> String { + let data = try readData(account: deviceIdKey, label: "device ID") + guard let str = String(data: data, encoding: .utf8) else { + throw NSEAuthError.keychainError("device ID is not valid UTF-8") + } + return str + } + + // MARK: - Team ID + + static func getTeamId() throws -> String { + let data = try readData(account: teamIdKey, label: "team ID") + guard let str = String(data: data, encoding: .utf8) else { + throw NSEAuthError.keychainError("team ID is not valid UTF-8") + } + return str + } + + // MARK: - Last sync timestamp (UserDefaults — not sensitive) + + static func getLastSyncTimestamp() -> Int64 { + return Int64(UserDefaults.standard.double(forKey: lastSyncKey)) + } + + static func saveLastSyncTimestamp(_ ts: Int64) { + UserDefaults.standard.set(Double(ts), forKey: lastSyncKey) + } + + // MARK: - Private helpers + + // Must match the App Group entitlement in both the main app and NSE targets + // (Signing & Capabilities → App Groups → group.com.quietmobile). + private static let accessGroup = "group.com.quietmobile" + + private static func readData(account: String, label: String) throws -> Data { + // Main app must write with kSecAttrAccessibleAfterFirstUnlock for NSE to read while device is locked + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { + throw NSEAuthError.keychainError("Unexpected type for \(label)") + } + return data + case errSecItemNotFound: + throw NSEAuthError.missingCredentials(label) + default: + throw NSEAuthError.keychainError("SecItemCopyMatching failed for \(label): \(status)") + } + } +} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift new file mode 100644 index 0000000000..5ee3af1ea4 --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift @@ -0,0 +1,94 @@ +import Foundation + +// MARK: - Error Types + +enum NSEAuthError: Error, LocalizedError { + case challengeRequestFailed(statusCode: Int) + case tokenRequestFailed(statusCode: Int) + case logFetchFailed(statusCode: Int) + case invalidResponse + case decodingFailed(Error) + case signingFailed + case keychainError(String) + case missingCredentials(String) + case networkError(Error) + + var errorDescription: String? { + switch self { + case .challengeRequestFailed(let code): return "Challenge request failed with status \(code)" + case .tokenRequestFailed(let code): return "Token request failed with status \(code)" + case .logFetchFailed(let code): return "Log fetch failed with status \(code)" + case .invalidResponse: return "Invalid or unexpected server response" + case .decodingFailed(let err): return "Decoding failed: \(err.localizedDescription)" + case .signingFailed: return "Failed to sign challenge" + case .keychainError(let msg): return "Keychain error: \(msg)" + case .missingCredentials(let field): return "Missing credential: \(field)" + case .networkError(let err): return "Network error: \(err.localizedDescription)" + } + } +} + +// MARK: - Challenge + +struct ChallengePayload: Codable { + let type: String + let name: String + let nonce: String + let timestamp: Int64 // Unix ms, from identity.challenge() in TypeScript +} + +struct ChallengeResponse: Codable { + let challengeId: String + let challenge: ChallengePayload +} + +// MARK: - Token + +struct ProofPayload: Codable { + let signature: String +} + +struct TokenRequest: Codable { + let challengeId: String + let deviceId: String + let proof: ProofPayload +} + +struct TokenResponse: Codable { + let token: String + let expiresIn: Int +} + +// MARK: - Log Entries + +// Matches QSS LogSyncEntry from LogEntrySyncStorageService +struct LogEntry: Decodable { + let cid: String // OrbitDB entry hash/CID + let hashedDbId: String // Hashed OrbitDB log ID + let communityId: String // Team ID + let entry: Data // Raw EncryptedAndSignedPayload bytes + let receivedAt: String // ISO 8601 UTC string + + private enum CodingKeys: String, CodingKey { + case cid, hashedDbId, communityId, entry, receivedAt + } + + // Node.js Buffer serializes to JSON as {"type":"Buffer","data":[byte,...]} + private struct NodeBuffer: Decodable { + let data: [UInt8] + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + cid = try c.decode(String.self, forKey: .cid) + hashedDbId = try c.decode(String.self, forKey: .hashedDbId) + communityId = try c.decode(String.self, forKey: .communityId) + receivedAt = try c.decode(String.self, forKey: .receivedAt) + let buffer = try c.decode(NodeBuffer.self, forKey: .entry) + entry = Data(buffer.data) + } +} + +struct LogEntriesResponse: Decodable { + let entries: [LogEntry] +} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift new file mode 100644 index 0000000000..e946130e6d --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift @@ -0,0 +1,111 @@ +import Foundation + +class NSENetworkClient { + let baseURL: URL + let session: URLSession + + // Shared encoder/decoder to avoid reallocating on every call. + private static let encoder: JSONEncoder = { + let e = JSONEncoder() + e.outputFormatting = .sortedKeys + return e + }() + + private static let decoder = JSONDecoder() + + // Dedicated session with tight timeouts suitable for an NSE (30-second budget). + private static let defaultSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 10 + config.timeoutIntervalForResource = 20 + return URLSession(configuration: config) + }() + + init(baseURL: URL, session: URLSession? = nil) { + self.baseURL = baseURL + self.session = session ?? NSENetworkClient.defaultSession + } + + // MARK: - POST /nse-auth/challenge + + func requestChallenge(deviceId: String, teamId: String) async throws -> ChallengeResponse { + let url = baseURL.appendingPathComponent("nse-auth/challenge") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = ["deviceId": deviceId, "teamId": teamId] + request.httpBody = try Self.encoder.encode(body) + + return try await perform(request: request, as: ChallengeResponse.self) { code in + throw NSEAuthError.challengeRequestFailed(statusCode: code) + } + } + + // MARK: - POST /nse-auth/token + + func requestToken(challengeId: String, deviceId: String, proof: ProofPayload) async throws -> TokenResponse { + let url = baseURL.appendingPathComponent("nse-auth/token") + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = TokenRequest(challengeId: challengeId, deviceId: deviceId, proof: proof) + request.httpBody = try Self.encoder.encode(body) + + return try await perform(request: request, as: TokenResponse.self) { code in + throw NSEAuthError.tokenRequestFailed(statusCode: code) + } + } + + // MARK: - GET /nse-auth/logs/:teamId + + func fetchLogEntries(teamId: String, since: Int64, token: String) async throws -> LogEntriesResponse { + guard let urlComponents = URLComponents( + url: baseURL.appendingPathComponent("nse-auth/logs/\(teamId)"), + resolvingAgainstBaseURL: false + ) else { throw NSEAuthError.invalidResponse } + var components = urlComponents + components.queryItems = [URLQueryItem(name: "since", value: String(since))] + + guard let url = components.url else { throw NSEAuthError.invalidResponse } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + return try await perform(request: request, as: LogEntriesResponse.self) { code in + throw NSEAuthError.logFetchFailed(statusCode: code) + } + } + + // MARK: - Private helper + + private func perform( + request: URLRequest, + as type: T.Type, + onError: (Int) throws -> Void + ) async throws -> T { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw NSEAuthError.networkError(error) + } + + guard let http = response as? HTTPURLResponse else { + throw NSEAuthError.invalidResponse + } + + guard (200..<300).contains(http.statusCode) else { + try onError(http.statusCode) + throw NSEAuthError.invalidResponse // unreachable; onError always throws + } + + do { + return try Self.decoder.decode(T.self, from: data) + } catch { + throw NSEAuthError.decodingFailed(error) + } + } +} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 479f08a346..65a3d38369 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -2,42 +2,96 @@ // NotificationService.swift // QuietNotificationServiceExtension // -// Created by jakebot on 2026-03-10. +// Created by Taea Vogel on 3/12/26. // import UserNotifications +import os.log + +private let nseLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NotificationService") -/// Notification Service Extension -/// This extension runs when a notification is received and can modify it before displaying -/// -/// For Quiet: We use notifications as wake-up signals to tell the app to fetch new content -/// No sensitive data is sent through notifications - just metadata class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? + var fetchTask: Task? + + private static let iso8601 = ISO8601DateFormatter() + private let crypto = NSECryptoService() + private var authCache: [URL: NSEAuthService] = [:] - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - if let bestAttemptContent = bestAttemptContent { - print("Notification received in service extension") - bestAttemptContent.title = "Quiet" - bestAttemptContent.body = "You have new activity" - bestAttemptContent.sound = .default - - print("Notification updated: \(bestAttemptContent.title)") - - contentHandler(bestAttemptContent) + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + + fetchTask = Task { + await fetchAndUpdate(userInfo: request.content.userInfo) } } override func serviceExtensionTimeWillExpire() { - // Called just before the extension will be terminated by the system - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - print("Extension time expiring - delivering notification") - contentHandler(bestAttemptContent) + fetchTask?.cancel() + deliver() + } + + // MARK: - Private + + private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async { + defer { deliver() } + + guard + let teamId = userInfo["teamId"] as? String, + let qssUrlString = userInfo["qssUrl"] as? String, + let qssUrl = URL(string: qssUrlString) + else { + return } + + do { + let auth: NSEAuthService + if let cached = authCache[qssUrl] { + auth = cached + } else { + let client = NSENetworkClient(baseURL: qssUrl) + let newAuth = NSEAuthService(client: client, crypto: crypto) + authCache[qssUrl] = newAuth + auth = newAuth + } + + let since = NSEKeychainHelper.getLastSyncTimestamp() + let entries = try await auth.fetchNewEntries(teamId: teamId, since: since) + + guard !Task.isCancelled else { return } + + if !entries.isEmpty { + let newTs = entries.lazy + .compactMap { Self.iso8601.date(from: $0.receivedAt) } + .map { Int64($0.timeIntervalSince1970 * 1000) } + .max() + if let newTs { + NSEKeychainHelper.saveLastSyncTimestamp(newTs) + } else { + // All receivedAt failed to parse — advance by 1ms to avoid reprocessing + os_log("All receivedAt timestamps failed to parse; advancing sync pointer", log: nseLog, type: .fault) + NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1) + } + + guard let content = bestAttemptContent else { return } + content.badge = ((content.badge?.intValue ?? 0) + entries.count) as NSNumber + } + } catch { + os_log("fetchAndUpdate failed: %{public}@", log: nseLog, type: .error, String(describing: error)) + } + } + + private func deliver() { + guard let handler = contentHandler, let content = bestAttemptContent else { return } + // Nil contentHandler first to prevent double-delivery if serviceExtensionTimeWillExpire + // races with task completion — both paths call deliver(), only the first wins. + contentHandler = nil + handler(content) } } From a08be816d7f5c6b39c3daa97c93b2ed01abd119e Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:08:33 -0400 Subject: [PATCH 25/92] fix NSE UserDefaults app group + add NSE_JWT_SECRET env placeholder - NSEKeychainHelper lastSyncTimestamp now uses shared UserDefaults(suiteName:"group.com.quietmobile") so main app can seed initial value and NSE reads same store - Worklog session 1 appended NSE_JWT_SECRET added in qss submodule (.env.local.docker); committed separately via submodule. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_WORKLOG.md | 139 ++++++++++++++++++ .../NSEKeychainHelper.swift | 14 +- 2 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 CLAUDE_WORKLOG.md diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md new file mode 100644 index 0000000000..982eb4d5fd --- /dev/null +++ b/CLAUDE_WORKLOG.md @@ -0,0 +1,139 @@ +You are continuing implementation of iOS push notifications for the + Quiet app (branch: fix/notification-mvp-tweaks in + /Users/taea/dev/quiet). + + ## What this feature does + When a Quiet community member sends a message, FCM fires a push + notification to all iOS devices. The iOS Notification Service + Extension (NSE) intercepts it, authenticates with QSS, fetches new + OrbitDB log entries, and increments the badge count — all before the + notification is displayed. + + ## Session 0 + + ### QSS backend (3rd-party/qss/) + - NEW: `app/src/nest/nse-auth/` module with three REST endpoints: + - `POST /nse-auth/challenge` — issues LFA-style challenge + `{type:'DEVICE', name:deviceId, nonce, timestamp}` + - `POST /nse-auth/token` — verifies Ed25519 signature (libsodium + + msgpackr) and returns 15-min JWT + - `GET /nse-auth/logs/:teamId?since=` — JWT-guarded; returns + log entries with Buffer serialized as `{type:'Buffer',data:[...]}` + - TODO: Add `NSE_JWT_SECRET` env var to `.env.local.docker` and prod + env + + ### iOS NSE (packages/mobile/ios/QuietNotificationServiceExtension/) + - `NotificationService.swift` — full fetch/auth/badge flow (reads + teamId+qssUrl from APNs payload) + - `NSEAuthService.swift`, `NSENetworkClient.swift`, + `NSECryptoService.swift`, `NSEKeychainHelper.swift`, + `NSEModels.swift` — complete auth+fetch stack + - `NSECryptoService` signs with CryptoKit Ed25519; `ProofPayload` + now includes both `signature` and `publicKey` + - `NSEKeychainHelper.getDevicePrivateKey` Base58-decodes the stored + LFA key before returning raw bytes + - All three entitlements files now declare `group.com.quietmobile` + in `com.apple.security.application-groups` + + ### Device credentials pipeline (new end-to-end) + - `@quiet/types`: `DeviceCredentialsUpdatedEvent` type + + `SocketEvents.DEVICE_CREDENTIALS_UPDATED` + - `sigchain.service.ts`: emits deviceId, teamId, signing private key + on iOS on every chain update + - Mobile state-manager: new `saveDeviceCredentials` action/saga + wired to the socket event + - `CommunicationModule.swift`: + `saveDeviceCredentials(_:teamId:signingPrivateKey:)` writes to + Keychain with `group.com.quietmobile` + + `kSecAttrAccessibleAfterFirstUnlock` + - `CommunicationBridge.m`: ObjC bridge registered + + ### FCM payload fix (packages/backend/) + - `qps.service.ts` `sendBatchPush` now injects `teamId` and `qssUrl` + into the FCM data payload so the NSE guard clauses pass + + ### LAN config (3rd-party/qss/app/) + - `docker-compose.quiet.yml`: bridge binding changed from + `127.0.0.1` → `0.0.0.0` + - `.env.local.docker`: `QSS_HOSTNAME=192.168.1.175` (update if IP + changes) + - Quiet backend needs `QSS_ENDPOINT=http://192.168.1.175:3003` in + its env at launch + + ## Known remaining gaps + + 1. **Device registration with QSS NSE auth** — the + `/nse-auth/challenge` endpoint currently issues a challenge to any + deviceId without verifying the device is actually registered. + There's a TODO comment in `nse-auth.service.ts` to add UCAN-level + trust anchor verification (check that the public key in the proof + belongs to a UCAN registered for that device+team). + + 2. **`NSEKeychainHelper.lastSyncTimestamp` uses + `UserDefaults.standard`** — this is NOT shared with the main app. If + you want to seed an initial timestamp (to avoid fetching all + history on first run), the main app should write it using + `UserDefaults(suiteName: "group.com.quietmobile")` and the NSE + should read from the same suite. + + 3. **`UserDefaults` for lastSyncTimestamp not in app group** — + `NSEKeychainHelper` uses `UserDefaults.standard` which is + process-local. Update both writer (if any) and reader to use + `UserDefaults(suiteName: "group.com.quietmobile")`. + + 4. **Rebuild needed** — after the `qps.service.ts` and + `sigchain.service.ts` changes, rebuild the backend bundle: `npx + lerna run prepare --scope @quiet/backend` + + 5. **QSS needs `NSE_JWT_SECRET`** — add to `.env.local.docker` for + stable JWT signing across restarts. + + 6. **Test flow** — once rebuilt and redeployed: + - Confirm `quiet.device.id`, `quiet.device.privateKey.*`, + `quiet.team.id` appear in Keychain (check Console.app for + `saveDeviceCredentials: stored` logs) + - Send a message; check Console.app filtered to + `com.quietmobile.QuietNotificationServiceExtension` + - Look for `fetchAndUpdate: fetching entries since=`, auth logs, + and badge update + + ## Architecture reference + - NSE files: + `packages/mobile/ios/QuietNotificationServiceExtension/` + - Main app bridge: `packages/mobile/ios/CommunicationModule.swift` + + `CommunicationBridge.m` + - Backend QPS: `packages/backend/src/nest/qps/qps.service.ts` + - QSS NSE auth: `3rd-party/qss/app/src/nest/nse-auth/` + - Types: `packages/types/src/keys.ts`, + `packages/types/src/socket.ts` + - Mobile sagas: `packages/mobile/src/store/keys/` + + Continue from here. + +## Session 1 + +### Fixed: UserDefaults → shared app group suite +- `NSEKeychainHelper.getLastSyncTimestamp` and `saveLastSyncTimestamp` now use + `UserDefaults(suiteName: "group.com.quietmobile")` instead of `UserDefaults.standard`. +- This allows the main app to seed an initial timestamp, and the NSE to read + the same value — both run in the same App Group. + +### Fixed: NSE_JWT_SECRET added to .env.local.docker +- Added `NSE_JWT_SECRET=change-me-in-production` to + `3rd-party/qss/app/.env.local.docker`. +- Without this, QSS generates a random per-process fallback secret so tokens + become invalid across restarts. + +## Known remaining gaps + +1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId + without UCAN-level verification. See TODO in `nse-auth.service.ts`. + +2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend` + needed after `qps.service.ts` / `sigchain.service.ts` changes. + +3. **Test flow** — rebuild → redeploy QSS (`docker compose ... up`) → launch + iOS, join community → check Console.app for: + - `saveDeviceCredentials: stored` (main app) + - `fetchAndUpdate: fetching entries since=` (NSE) + - badge increment on next message diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index 16d3dfd2e4..0dc8ade0c8 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -14,7 +14,13 @@ struct NSEKeychainHelper { static func getDevicePrivateKey(deviceId: String) throws -> Data { let account = devicePrivateKeyPrefix + deviceId - return try readData(account: account, label: "device private key") + // Stored as UTF-8 Base58 string (LFA secretKey format); decode to raw bytes + let rawData = try readData(account: account, label: "device private key") + guard let base58String = String(data: rawData, encoding: .utf8), + let keyBytes = Base58.decode(base58String) else { + throw NSEAuthError.keychainError("device private key is not valid Base58") + } + return keyBytes } // MARK: - Device ID @@ -40,11 +46,13 @@ struct NSEKeychainHelper { // MARK: - Last sync timestamp (UserDefaults — not sensitive) static func getLastSyncTimestamp() -> Int64 { - return Int64(UserDefaults.standard.double(forKey: lastSyncKey)) + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + return Int64(defaults.double(forKey: lastSyncKey)) } static func saveLastSyncTimestamp(_ ts: Int64) { - UserDefaults.standard.set(Double(ts), forKey: lastSyncKey) + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + defaults.set(Double(ts), forKey: lastSyncKey) } // MARK: - Private helpers From 1e07df2cb3fe7389dad87ad63a2a3622fe6cdbdf Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:15:28 -0400 Subject: [PATCH 26/92] update qss submodule: NSE_JWT_SECRET + lint fixes Co-Authored-By: Claude Sonnet 4.6 --- 3rd-party/qss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rd-party/qss b/3rd-party/qss index 2e43e197f9..f75262eef8 160000 --- a/3rd-party/qss +++ b/3rd-party/qss @@ -1 +1 @@ -Subproject commit 2e43e197f91ffa4731f5da2f46dbe21977f65940 +Subproject commit f75262eef84ea2be6378bd2b3b9174afdd29b21e From 8c45568c00f627ec347bd2756c5f58fcdf9b08ee Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:21:02 -0400 Subject: [PATCH 27/92] fix NSEMsgpack: map16 header + float64 timestamp to match msgpackr msgpackr always emits map16 (0xde 0x00 0x04) for objects and encodes integers > 2^32 as float64. Both mismatches caused signature bytes to differ from QSS verification, so auth always failed silently. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_WORKLOG.md | 25 +++++++ .../NSECryptoService.swift | 70 +++++++++++-------- 2 files changed, 64 insertions(+), 31 deletions(-) diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md index 982eb4d5fd..5fb8f0d23e 100644 --- a/CLAUDE_WORKLOG.md +++ b/CLAUDE_WORKLOG.md @@ -124,6 +124,31 @@ You are continuing implementation of iOS push notifications for the - Without this, QSS generates a random per-process fallback secret so tokens become invalid across restarts. +## Session 2 + +### Fixed: NSEMsgpack encoding mismatch (critical — signatures always failed) + +`msgpackr.pack()` uses two non-obvious encodings that the hand-rolled Swift +encoder was getting wrong, causing signature verification to always fail: + +1. **Map header**: msgpackr uses `map16` (`0xde 0x00 0x04`) for ALL object + sizes, never `fixmap`. The Swift encoder was emitting `0x84` (fixmap). + +2. **Timestamp encoding**: JavaScript's `Date.now()` returns ~1.7e12, which + exceeds 2^32. msgpackr encodes values > 2^32 as `float64` (`0xcb` + 8-byte + IEEE 754), not `uint64`. The Swift encoder was emitting `uint64` (`0xcf`). + +Verification (msgpackr output): +``` +de0004 a474797065 a644455649... a974696d657374616d70 cb4278e5f8c1600000 +^^^ ^^ float64, not 0xcf +map16 +``` + +Fix: updated `NSEMsgpack.encode()` in `NSECryptoService.swift` to emit +`map16` header and `appendFloat64()` instead of `appendUInt64()` for the +timestamp field. + ## Known remaining gaps 1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift index 5bfc26425c..8f1b12ff39 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -15,8 +15,17 @@ protocol DeviceCryptography { extension DeviceCryptography { func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload { let payloadBytes = try NSEMsgpack.encode(challenge) - let signatureBytes = try signBytes(payloadBytes, privateKeyData: privateKeyData) - return ProofPayload(signature: Base58.encode(signatureBytes)) + guard privateKeyData.count == 64 || privateKeyData.count == 32 else { + throw NSECryptoError.invalidKeyLength(expected: 64, got: privateKeyData.count) + } + let seed = privateKeyData.prefix(32) + let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed) + let signatureBytes = try privateKey.signature(for: payloadBytes) + let publicKeyBytes = privateKey.publicKey.rawRepresentation + return ProofPayload( + signature: Base58.encode(signatureBytes), + publicKey: Base58.encode(publicKeyBytes) + ) } func signBytes(_ message: Data, privateKeyData: Data) throws -> Data { @@ -63,8 +72,13 @@ class NSECryptoService: DeviceCryptography { } // MARK: - Msgpack encoder -// Encodes the ChallengePayload object in the same format as msgpackr.pack(): +// Encodes the ChallengePayload object in the same byte format as msgpackr.pack(): // { type: string, name: string, nonce: string, timestamp: number } +// +// msgpackr quirks that must be matched exactly: +// 1. Objects always use map16 format (0xde + 2-byte count), never fixmap. +// 2. Integers > 2^32 (e.g. Date.now() in ms) are encoded as float64 (0xcb). +// 3. Strings 0–31 bytes → fixstr (0xa0|len); 32–255 bytes → str8 (0xd9, len). // Field order must exactly match the JS object insertion order. enum NSEMsgpack { @@ -73,8 +87,11 @@ enum NSEMsgpack { /// Encodes a ChallengePayload in the same byte format as msgpackr.pack(). static func encode(_ challenge: ChallengePayload) throws -> Data { var out = Data() - // fixmap with 4 elements: 0x84 - out.append(0x84) + // map16 with 4 elements: 0xde 0x00 0x04 + // msgpackr always uses map16, never fixmap, regardless of element count. + out.append(0xde) + out.append(0x00) + out.append(0x04) // key: "type" value: challenge.type try appendString("type", to: &out) try appendString(challenge.type, to: &out) @@ -84,9 +101,11 @@ enum NSEMsgpack { // key: "nonce" value: challenge.nonce try appendString("nonce", to: &out) try appendString(challenge.nonce, to: &out) - // key: "timestamp" value: challenge.timestamp (Unix ms, Int64) + // key: "timestamp" value: challenge.timestamp + // Date.now() returns ms since epoch (~1.7e12), which exceeds 2^32. + // msgpackr encodes values > 2^32 as float64, not uint64. try appendString("timestamp", to: &out) - appendUInt64(UInt64(bitPattern: Int64(challenge.timestamp)), to: &out) + appendFloat64(Double(challenge.timestamp), to: &out) return out } @@ -111,29 +130,18 @@ enum NSEMsgpack { out.append(contentsOf: bytes) } - /// Encodes a positive integer as uint64 (0xcf + 8 bytes BE). - /// msgpackr uses uint64 for integers that exceed 2^32. - private static func appendUInt64(_ v: UInt64, to out: inout Data) { - if v <= 0x7F { - out.append(UInt8(v)) // positive fixint - } else if v <= 0xFF { - out.append(0xcc); out.append(UInt8(v)) // uint8 - } else if v <= 0xFFFF { - out.append(0xcd) - out.append(UInt8((v >> 8) & 0xFF)) - out.append(UInt8(v & 0xFF)) - } else if v <= 0xFFFFFFFF { - out.append(0xce) - out.append(UInt8((v >> 24) & 0xFF)) - out.append(UInt8((v >> 16) & 0xFF)) - out.append(UInt8((v >> 8) & 0xFF)) - out.append(UInt8(v & 0xFF)) - } else { - // uint64: 0xcf + 8 bytes big-endian (used for Date.now() timestamps) - out.append(0xcf) - for shift in stride(from: 56, through: 0, by: -8) { - out.append(UInt8((v >> shift) & 0xFF)) - } - } + /// Encodes a Double as IEEE 754 float64 (0xcb + 8 bytes big-endian). + /// msgpackr uses float64 for JavaScript numbers that exceed 2^32. + private static func appendFloat64(_ v: Double, to out: inout Data) { + out.append(0xcb) + let bits = v.bitPattern // UInt64 IEEE 754 representation + out.append(UInt8((bits >> 56) & 0xFF)) + out.append(UInt8((bits >> 48) & 0xFF)) + out.append(UInt8((bits >> 40) & 0xFF)) + out.append(UInt8((bits >> 32) & 0xFF)) + out.append(UInt8((bits >> 24) & 0xFF)) + out.append(UInt8((bits >> 16) & 0xFF)) + out.append(UInt8((bits >> 8) & 0xFF)) + out.append(UInt8(bits & 0xFF)) } } From 01009ae0d5657bff7407cdde197d5e4efe108620 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:24:18 -0400 Subject: [PATCH 28/92] =?UTF-8?q?fix=20NSE=20keychain=20read=20+=20qssUrl?= =?UTF-8?q?=20scheme=20(ws=E2=86=92http)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remove kSecAttrAccessible from SecItemCopyMatching; write-only attr can silently prevent matches on some iOS versions - qps.service: convert ws:// → http:// before including qssUrl in FCM payload; URLSession rejects ws:// scheme for HTTP data tasks Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_WORKLOG.md | 26 ++++++++++++++++--- packages/backend/src/nest/qps/qps.service.ts | 13 +++++++++- .../NSEKeychainHelper.swift | 15 ++++++----- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md index 5fb8f0d23e..a30e08865a 100644 --- a/CLAUDE_WORKLOG.md +++ b/CLAUDE_WORKLOG.md @@ -149,16 +149,34 @@ Fix: updated `NSEMsgpack.encode()` in `NSECryptoService.swift` to emit `map16` header and `appendFloat64()` instead of `appendUInt64()` for the timestamp field. +## Session 3 + +### Fixed: kSecAttrAccessible removed from Keychain read query +`NSEKeychainHelper.readData` was including `kSecAttrAccessible` in +`SecItemCopyMatching`. Apple's docs list it as a write attribute only; +including it in a search query can silently prevent matches on some iOS +versions. Removed from all read queries (accessibility is enforced at +write time in `CommunicationModule.swift`). + +### Fixed: qssUrl scheme mismatch (ws:// → http://) +`qps.service.ts` was sending `qssEndpoint` directly in the FCM data +payload. `qssEndpoint` is a WebSocket URL (`ws://host:port`). The NSE +uses this URL for HTTP REST calls via `URLSession.data(for:)`, which +does not support the `ws://` scheme and would throw +"unsupported URL scheme". Fix: replace `ws://` → `http://` and +`wss://` → `https://` before putting the URL in the FCM payload. + ## Known remaining gaps -1. **Device registration trust** — `/nse-auth/challenge` issues to any deviceId - without UCAN-level verification. See TODO in `nse-auth.service.ts`. +1. **Device registration trust** — `/nse-auth/challenge` issues to any + deviceId without UCAN-level verification (TODO comment in + `nse-auth.service.ts`). 2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend` needed after `qps.service.ts` / `sigchain.service.ts` changes. -3. **Test flow** — rebuild → redeploy QSS (`docker compose ... up`) → launch - iOS, join community → check Console.app for: +3. **Test flow** — rebuild → redeploy QSS → launch iOS, join community + → check Console.app for: - `saveDeviceCredentials: stored` (main app) - `fetchAndUpdate: fetching entries since=` (NSE) - badge increment on next message diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts index 071e6d15c0..fc3461d106 100644 --- a/packages/backend/src/nest/qps/qps.service.ts +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -151,6 +151,17 @@ export class QPSService implements OnModuleInit { batches.push(ucans.slice(i, i + PUSH_BATCH_SIZE)) } + // Convert ws:// → http:// (or wss:// → https://) so the NSE can use this + // URL for HTTP REST calls to /nse-auth/. URLSession does not support ws:// + // scheme for regular data tasks. + const wsUrl = this.qssService.qssEndpoint + const qssUrl = wsUrl?.replace(/^wss?:\/\//, match => (match === 'wss://' ? 'https://' : 'http://')) + const mergedData: Record = { + teamId, + ...(qssUrl != null ? { qssUrl } : {}), + ...data, + } + this.logger.info( `Triggering push notifications for team ${teamId} with ${ucans.length} UCAN(s) in ${batches.length} batch(es)` ) @@ -161,7 +172,7 @@ export class QPSService implements OnModuleInit { { ts: DateTime.utc().toMillis(), status: CommunityOperationStatus.SENDING, - payload: { ucans: batch, title, body, data }, + payload: { ucans: batch, title, body, data: mergedData }, }, true ) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index 0dc8ade0c8..6acc12302f 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -62,14 +62,15 @@ struct NSEKeychainHelper { private static let accessGroup = "group.com.quietmobile" private static func readData(account: String, label: String) throws -> Data { - // Main app must write with kSecAttrAccessibleAfterFirstUnlock for NSE to read while device is locked + // Note: kSecAttrAccessible is intentionally omitted — it's a write attribute. + // Including it in a read query can cause silent failures on some iOS versions. + // Accessibility is enforced at write time (kSecAttrAccessibleAfterFirstUnlock). let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrAccessGroup: accessGroup, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne, + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, ] var result: CFTypeRef? From e82de2e30c6c57bc3175d47c98003a485a7dff23 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:35:55 -0400 Subject: [PATCH 29/92] fix ISO8601 fractional seconds + session 4 audit NotificationService was using ISO8601DateFormatter() with default options; Luxon always emits milliseconds so every receivedAt parse failed, badge stayed wrong. Fixed with .withFractionalSeconds option. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE_WORKLOG.md | 48 ++++++++++---- .../NotificationService.swift | 64 ++++++++++++++++--- 2 files changed, 88 insertions(+), 24 deletions(-) diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md index a30e08865a..7fc05e06d5 100644 --- a/CLAUDE_WORKLOG.md +++ b/CLAUDE_WORKLOG.md @@ -166,17 +166,37 @@ does not support the `ws://` scheme and would throw "unsupported URL scheme". Fix: replace `ws://` → `http://` and `wss://` → `https://` before putting the URL in the FCM payload. -## Known remaining gaps - -1. **Device registration trust** — `/nse-auth/challenge` issues to any - deviceId without UCAN-level verification (TODO comment in - `nse-auth.service.ts`). - -2. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend` - needed after `qps.service.ts` / `sigchain.service.ts` changes. - -3. **Test flow** — rebuild → redeploy QSS → launch iOS, join community - → check Console.app for: - - `saveDeviceCredentials: stored` (main app) - - `fetchAndUpdate: fetching entries since=` (NSE) - - badge increment on next message +## Session 4 + +### Fixed: ISO8601DateFormatter missing .withFractionalSeconds +`NotificationService.swift` used `ISO8601DateFormatter()` with default +options to parse `receivedAt` timestamps from QSS log entries. Luxon's +`DateTime.toISO()` always includes milliseconds (`"...T10:00:00.000Z"`), +which the default formatter cannot parse — `compactMap` would return `[]` +for all entries, making `newTs` nil and triggering the "advancing sync +pointer by 1ms" fallback. Badge would always be wrong. +Fixed by configuring formatter with `[.withInternetDateTime, .withFractionalSeconds]`. + +### Verified clean: end-to-end flow audit +Full trace reviewed — no additional blocking issues found: +- `communityId` in QSS log entries = `sigchain.team.id` = `teamId` in FCM payload ✓ +- `LogEntriesResponse { entries: [...] }` matches Swift `LogEntriesResponse.entries` decoder ✓ +- `entry` NodeBuffer `{type:"Buffer",data:[...]}` decodes correctly to `Data` in Swift ✓ +- `kSecAttrAccessGroup: "group.com.quietmobile"` with App Group entitlement is valid + for cross-process Keychain sharing (NSE + main app, no keychain-access-groups needed) ✓ +- `KeychainHelper.swift` in NSE folder is unrelated dead code; NSE uses `NSEKeychainHelper` ✓ +- `process.platform === 'ios'` in nodejs-mobile confirmed by existing codebase patterns ✓ + +## Feature status: READY FOR TESTING + +All known blocking code bugs fixed across 4 sessions. Remaining items +require user action: + +1. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend` +2. **QSS redeploy** — `docker compose -f docker-compose.quiet.yml up -d` in `3rd-party/qss/app/` +3. **Test on device**: + - Launch app → join community → watch Console.app for `saveDeviceCredentials: stored` + - From another device, send a message + - Watch NSE Console.app for `fetchAndUpdate: teamId=`, `authenticate:`, `fetched X entries`, badge update +4. **UCAN trust** (post-MVP) — `/nse-auth/challenge` should verify the device public key + is registered in the @localfirst/auth sigchain for the teamId (TODO comment in `nse-auth.service.ts`) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 65a3d38369..4110776833 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -16,7 +16,14 @@ class NotificationService: UNNotificationServiceExtension { var bestAttemptContent: UNMutableNotificationContent? var fetchTask: Task? - private static let iso8601 = ISO8601DateFormatter() + // Luxon's toISO() always includes milliseconds (e.g. "2024-03-21T10:00:00.000Z"). + // The default ISO8601DateFormatter does not parse fractional seconds — + // withFractionalSeconds is required or every timestamp parse will fail. + private static let iso8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() private let crypto = NSECryptoService() private var authCache: [URL: NSEAuthService] = [:] @@ -24,6 +31,10 @@ class NotificationService: UNNotificationServiceExtension { _ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void ) { + os_log("didReceive: identifier=%{public}@", log: nseLog, type: .info, request.identifier) + os_log("didReceive: userInfo keys=%{public}@", log: nseLog, type: .info, + request.content.userInfo.keys.map { "\($0)" }.sorted().joined(separator: ", ")) + self.contentHandler = contentHandler bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent @@ -33,6 +44,7 @@ class NotificationService: UNNotificationServiceExtension { } override func serviceExtensionTimeWillExpire() { + os_log("serviceExtensionTimeWillExpire: delivering best-attempt content", log: nseLog, type: .error) fetchTask?.cancel() deliver() } @@ -42,19 +54,37 @@ class NotificationService: UNNotificationServiceExtension { private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async { defer { deliver() } - guard - let teamId = userInfo["teamId"] as? String, - let qssUrlString = userInfo["qssUrl"] as? String, - let qssUrl = URL(string: qssUrlString) - else { + os_log("fetchAndUpdate: start", log: nseLog, type: .debug) + + guard let teamId = userInfo["teamId"] as? String else { + os_log("fetchAndUpdate: missing 'teamId' in userInfo; payload keys=%{public}@", + log: nseLog, type: .error, + userInfo.keys.map { "\($0)" }.sorted().joined(separator: ", ")) return } + guard let qssUrlString = userInfo["qssUrl"] as? String else { + os_log("fetchAndUpdate: missing 'qssUrl' in userInfo (teamId=%{public}@)", + log: nseLog, type: .error, teamId) + return + } + + guard let qssUrl = URL(string: qssUrlString) else { + os_log("fetchAndUpdate: 'qssUrl' is not a valid URL: %{public}@", + log: nseLog, type: .error, qssUrlString) + return + } + + os_log("fetchAndUpdate: teamId=%{public}@ qssUrl=%{public}@", + log: nseLog, type: .info, teamId, qssUrlString) + do { let auth: NSEAuthService if let cached = authCache[qssUrl] { + os_log("fetchAndUpdate: using cached NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString) auth = cached } else { + os_log("fetchAndUpdate: creating new NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString) let client = NSENetworkClient(baseURL: qssUrl) let newAuth = NSEAuthService(client: client, crypto: crypto) authCache[qssUrl] = newAuth @@ -62,16 +92,25 @@ class NotificationService: UNNotificationServiceExtension { } let since = NSEKeychainHelper.getLastSyncTimestamp() + os_log("fetchAndUpdate: fetching entries since=%{public}lld", log: nseLog, type: .info, since) + let entries = try await auth.fetchNewEntries(teamId: teamId, since: since) + os_log("fetchAndUpdate: fetched %{public}d entries", log: nseLog, type: .info, entries.count) - guard !Task.isCancelled else { return } + guard !Task.isCancelled else { + os_log("fetchAndUpdate: task cancelled after fetch", log: nseLog, type: .info) + return + } - if !entries.isEmpty { + if entries.isEmpty { + os_log("fetchAndUpdate: no new entries, delivering as-is", log: nseLog, type: .info) + } else { let newTs = entries.lazy .compactMap { Self.iso8601.date(from: $0.receivedAt) } .map { Int64($0.timeIntervalSince1970 * 1000) } .max() if let newTs { + os_log("fetchAndUpdate: saving lastSyncTimestamp=%{public}lld", log: nseLog, type: .info, newTs) NSEKeychainHelper.saveLastSyncTimestamp(newTs) } else { // All receivedAt failed to parse — advance by 1ms to avoid reprocessing @@ -79,8 +118,13 @@ class NotificationService: UNNotificationServiceExtension { NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1) } - guard let content = bestAttemptContent else { return } - content.badge = ((content.badge?.intValue ?? 0) + entries.count) as NSNumber + guard let content = bestAttemptContent else { + os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error) + return + } + let newBadge = (content.badge?.intValue ?? 0) + entries.count + os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) + content.badge = newBadge as NSNumber } } catch { os_log("fetchAndUpdate failed: %{public}@", log: nseLog, type: .error, String(describing: error)) From d2b16a4d29bca06779dd4f29f2a795f5f13a4b0f Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 19:38:00 -0400 Subject: [PATCH 30/92] session 5: loop complete, feature ready for testing --- CLAUDE_WORKLOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md index 7fc05e06d5..5aa70c6b1d 100644 --- a/CLAUDE_WORKLOG.md +++ b/CLAUDE_WORKLOG.md @@ -200,3 +200,11 @@ require user action: - Watch NSE Console.app for `fetchAndUpdate: teamId=`, `authenticate:`, `fetched X entries`, badge update 4. **UCAN trust** (post-MVP) — `/nse-auth/challenge` should verify the device public key is registered in the @localfirst/auth sigchain for the teamId (TODO comment in `nse-auth.service.ts`) + +## Session 5 + +### Loop terminating — feature complete + +No remaining blocking code bugs. All 4 sessions of fixes are committed. Remaining items +are all user-action (rebuild, redeploy, device test) or post-MVP (UCAN trust). +Recurring cron job f1f8b51a deleted. From 420cacf750ccc92be84a8e8f909c972c02b94d0c Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 24 Mar 2026 22:05:30 -0400 Subject: [PATCH 31/92] update auth submodule to match qss (397a62f) --- 3rd-party/auth | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rd-party/auth b/3rd-party/auth index c44019ab9a..397a62f10b 160000 --- a/3rd-party/auth +++ b/3rd-party/auth @@ -1 +1 @@ -Subproject commit c44019ab9a9323bf53f58ed4bfb1ff44827562a1 +Subproject commit 397a62f10b1234d260f37945a10f46a7a62c00bc From a98461fc74c70d179ab74ada8ab517d3b9dfe9ea Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 25 Mar 2026 15:45:49 -0400 Subject: [PATCH 32/92] working prototype --- .gitignore | 1 + .../backend/src/nest/auth/sigchain.service.ts | 30 +- .../connections-manager.service.ts | 2 + packages/backend/src/nest/qss/qss.service.ts | 7 +- packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 46 +- packages/mobile/ios/KeychainHandler.swift | 97 ++- packages/mobile/ios/Podfile | 5 + packages/mobile/ios/Podfile.lock | 6 +- packages/mobile/ios/Quiet.debug.xcconfig | 2 + packages/mobile/ios/Quiet.release.xcconfig | 2 + .../ios/Quiet.xcodeproj/project.pbxproj | 155 +++-- packages/mobile/ios/Quiet/Info.plist | 2 + packages/mobile/ios/Quiet/Quiet.entitlements | 4 +- .../mobile/ios/Quiet/QuietDebug.entitlements | 4 +- ...otificationServiceExtension.debug.xcconfig | 2 + ...ificationServiceExtension.release.xcconfig | 2 + .../Info.plist | 2 + .../NSEAuthService.swift | 18 +- .../NSECryptoService.swift | 554 +++++++++++++++++- .../NSEKeychainHelper.swift | 25 +- .../NSEModels.swift | 1 + .../NSENetworkClient.swift | 16 + .../NotificationService.swift | 40 +- ...tNotificationServiceExtension.entitlements | 8 +- .../startConnection/startConnection.saga.ts | 7 +- .../mobile/src/store/keys/keys.master.saga.ts | 6 +- packages/mobile/src/store/keys/keys.slice.ts | 3 +- .../saveDeviceCredentials.saga.ts | 22 + packages/types/src/keys.ts | 7 + packages/types/src/socket.ts | 4 +- 31 files changed, 972 insertions(+), 109 deletions(-) create mode 100644 packages/mobile/ios/Quiet.debug.xcconfig create mode 100644 packages/mobile/ios/Quiet.release.xcconfig create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig create mode 100644 packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig create mode 100644 packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts diff --git a/.gitignore b/.gitignore index 5d316e1825..3782f31124 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ packages/.DS_Store *.log clangd/ **/.claude/settings.local.json +.claude/worktrees # Yarn diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index cfff887d84..54cc3cfb3e 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -24,7 +24,7 @@ import { SERVER_IO_PROVIDER } from '../const' import { ServerIoProviderTypes } from '../types' import EventEmitter from 'events' import { GetChainFilter, StoredKeyType } from './types' -import { KeysUpdatedEvent } from '@quiet/types' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from '@quiet/types' @Injectable() export class SigChainService extends EventEmitter { @@ -144,6 +144,7 @@ export class SigChainService extends EventEmitter { private handleChainUpdate = (teamName: string) => { this._updateUsersOnChainUpdate(teamName) this._updateKeysOnChainUpdate(teamName) + this._updateDeviceCredentials(teamName) this.emit('updated', teamName) this.saveChain(teamName) this.logger.info('Chain updated, emitted updated event') @@ -242,6 +243,33 @@ export class SigChainService extends EventEmitter { this.serverIoProvider.io.emit(SocketEvents.KEYS_UPDATED, keyUpdateEvent) } + /** + * Emit device credentials to iOS so the NSE can authenticate with QSS. + * Only runs on iOS; no-ops on other platforms. + */ + private _updateDeviceCredentials(teamName: string): void { + if ((process.platform as string) !== 'ios') return + try { + const sigchain = this.getChain({ teamName }) + if (sigchain?.team == null) return + const teamId = sigchain.team.id + const device = sigchain.device + if (!device?.deviceId || !device.keys?.signature?.secretKey) { + this.logger.warn('Device credentials not available, skipping NSE credential update') + return + } + const event: DeviceCredentialsUpdatedEvent = { + deviceId: device.deviceId, + teamId, + signingPrivateKey: device.keys.signature.secretKey, + } + this.serverIoProvider.io.emit(SocketEvents.DEVICE_CREDENTIALS_UPDATED, event) + this.logger.info('Emitted device credentials for NSE') + } catch (e) { + this.logger.error('Failed to emit device credentials', e) + } + } + private attachSocketListeners(chain: SigChain): void { this.logger.info('Attaching socket listeners') const listener = (): void => { diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index d1ac5c5970..32ba736d90 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -116,6 +116,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI }) ) + this.logger.info('QSS_ENDPOINT', this.qssEndpoint) + await this.init() } diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 47a8c796eb..7e99cb4f29 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -609,13 +609,18 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.startLogPullInterval(teamId) } - authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, startLogPullInterval) + authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, () => { + this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED) + startLogPullInterval() + }) authConnection?.on(QSSEvents.QSS_DISCONNECTED, () => { this.logger.info('Disconnected event received, stopping log entry pull interval', teamId) + this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_DISCONNECTED) this._stopLogPullInterval(teamId) }) if (authConnection?.active) { + this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED) startLogPullInterval() } } diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 9212ba937e..50b6c804a4 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -7,4 +7,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(checkNotificationPermission) RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys) RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata) +RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index cb57fd7569..5c37920ec0 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -19,7 +19,7 @@ class CommunicationModule: RCTEventEmitter { let userMetadataHandler = UserMetadataHandler() private var hasListeners = false - + @objc func sendDataPort(port: UInt16, socketIOSecret: String) { self.sendEvent(withName: CommunicationModule.BACKEND_EVENT_IDENTIFIER, body: ["channelName": CommunicationModule.WEBSOCKET_CONNECTION_CHANNEL, "payload": ["dataPort": port, "socketIOSecret": socketIOSecret]]) @@ -81,6 +81,50 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func saveDeviceCredentials(_ deviceId: NSString, teamId: NSString, signingPrivateKey: NSString) { + let deviceIdStr = deviceId as String + let teamIdStr = teamId as String + let keyStr = signingPrivateKey as String + let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String + + func writeItem(account: String, value: String) { + guard let data = value.data(using: .utf8) else { + CommunicationModule.logger.error("saveDeviceCredentials: failed to encode \(account) as UTF-8") + return + } + // Delete any existing item first to allow updates + var deleteQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + ] + if let accessGroup { + deleteQuery[kSecAttrAccessGroup] = accessGroup + } + SecItemDelete(deleteQuery as CFDictionary) + + var addQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + kSecValueData: data, + ] + if let accessGroup { + addQuery[kSecAttrAccessGroup] = accessGroup + } + let status = SecItemAdd(addQuery as CFDictionary, nil) + if status != errSecSuccess { + CommunicationModule.logger.error("saveDeviceCredentials: SecItemAdd failed for \(account): \(status)") + } else { + CommunicationModule.logger.info("saveDeviceCredentials: stored \(account)") + } + } + + writeItem(account: "quiet.device.id", value: deviceIdStr) + writeItem(account: "quiet.team.id", value: teamIdStr) + writeItem(account: "quiet.device.privateKey.\(deviceIdStr)", value: keyStr) + } + @objc func saveUserMetadata(_ updatedMetadata: NSArray) { let decoder = JSONDecoder() diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index 355365f045..7ad76b1409 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -41,16 +41,27 @@ public struct NamedKey: Codable { // TODO: add string to key object conversion (e.g. string to SymmetricKey) @objc(KeychainHandler) class KeychainHandler: NSObject { - private let keychainGroupName: String = "com.quietmobile" + private let keychainService: String = "com.quietmobile" + private lazy var accessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler") public func getLfaKeyString(keyName: String) throws -> String { do { - let password: String = try _getKeyImpl(keyName: keyName) + let password: String = try _getKeyImpl(keyName: keyName, includeAccessGroup: true) return password } catch KeychainError.noPassword { - throw KeychainHandlerError.noKeyFound + do { + let password = try _getKeyImpl(keyName: keyName, includeAccessGroup: false) + migrateLegacyKeyIfNeeded(keyName: keyName, value: password) + return password + } catch KeychainError.noPassword { + throw KeychainHandlerError.noKeyFound + } catch KeychainError.unexpectedPasswordData { + throw KeychainHandlerError.malformedKey + } catch { + throw KeychainHandlerError.unhandledError(reason: error) + } } catch KeychainError.unexpectedPasswordData { throw KeychainHandlerError.malformedKey } catch ConversionError.stringToBytesError { @@ -61,42 +72,48 @@ class KeychainHandler: NSObject { } public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus { - var existingKey: String? - do { - existingKey = try getLfaKeyString(keyName: namedKey.keyName) - } catch KeychainHandlerError.noKeyFound { - existingKey = nil - } catch KeychainHandlerError.malformedKey { - existingKey = nil - } catch { - KeychainHandler.logger.error("Error while getting existing LFA key for name \(namedKey.keyName): \(error)") - throw error + if let sharedKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: true) { + guard sharedKey == namedKey.key else { + return KeyAddStatus.duplicateScope + } + return KeyAddStatus.success } - guard existingKey == nil else { - guard existingKey == namedKey.key else { return KeyAddStatus.duplicateScope } - return KeyAddStatus.success + if let legacyKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false) { + guard legacyKey == namedKey.key else { + return KeyAddStatus.duplicateScope + } } do { let keyData: Data = try _stringToBytes(str: namedKey.key) - let addStatus: KeyAddStatus = try _addKeyToKeychainImpl(keyName: namedKey.keyName, keyData: keyData) + let addStatus: KeyAddStatus = try _addKeyToKeychainImpl( + keyName: namedKey.keyName, + keyData: keyData, + includeAccessGroup: true + ) + if addStatus == .success { + try? _deleteKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false) + } return addStatus } catch { throw KeychainHandlerError.unhandledError(reason: error) } } - private func _getKeyImpl(keyName: String) throws -> String { + private func _getKeyImpl(keyName: String, includeAccessGroup: Bool) throws -> String { var existingKey: CFTypeRef? - let query: [String: Any] = [ + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainGroupName, + kSecAttrService as String: keychainService, kSecAttrAccount as String: keyName, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnAttributes as String: true, kSecReturnData as String: true ] + if includeAccessGroup, let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey) guard status != errSecItemNotFound else { throw KeychainError.noPassword } guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } @@ -109,14 +126,17 @@ class KeychainHandler: NSObject { return password } - private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus { - let query: [String: Any] = [ + private func _addKeyToKeychainImpl(keyName: String, keyData: Data, includeAccessGroup: Bool) throws -> KeyAddStatus { + var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: keyName, - kSecAttrService as String: keychainGroupName, + kSecAttrService as String: keychainService, kSecValueData as String: keyData, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] + if includeAccessGroup, let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } let status: OSStatus = SecItemAdd(query as CFDictionary, nil) if status == errSecSuccess { @@ -133,4 +153,35 @@ class KeychainHandler: NSObject { guard bytes != nil else { throw ConversionError.stringToBytesError } return bytes! } + + private func _deleteKeyImpl(keyName: String, includeAccessGroup: Bool) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + kSecAttrAccount as String: keyName, + ] + if includeAccessGroup, let accessGroup { + query[kSecAttrAccessGroup as String] = accessGroup + } + + let status = SecItemDelete(query as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.unhandledError(status: status) + } + } + + private func migrateLegacyKeyIfNeeded(keyName: String, value: String) { + guard let data = value.data(using: .utf8) else { + return + } + + do { + let addStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true) + if addStatus == .success { + try? _deleteKeyImpl(keyName: keyName, includeAccessGroup: false) + } + } catch { + KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)") + } + } } diff --git a/packages/mobile/ios/Podfile b/packages/mobile/ios/Podfile index 82cd61c7b3..ef6ce514a5 100644 --- a/packages/mobile/ios/Podfile +++ b/packages/mobile/ios/Podfile @@ -68,3 +68,8 @@ target 'Quiet' do end end end + +target 'QuietNotificationServiceExtension' do + use_frameworks! :linkage => :static + pod 'Sodium' +end diff --git a/packages/mobile/ios/Podfile.lock b/packages/mobile/ios/Podfile.lock index c3e895a41d..1344addc09 100644 --- a/packages/mobile/ios/Podfile.lock +++ b/packages/mobile/ios/Podfile.lock @@ -1725,6 +1725,7 @@ PODS: - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - SocketRocket (0.7.1) + - Sodium (0.9.1) - SSZipArchive (2.6.0) - Tor (405.9.1) - Yoga (0.0.0) @@ -1818,6 +1819,7 @@ DEPENDENCIES: - RNScreens (from `../node_modules/react-native-screens`) - RNShare (from `../node_modules/react-native-share`) - RNSVG (from `../node_modules/react-native-svg`) + - Sodium - Tor (from `https://raw.githubusercontent.com/iCepa/Tor.framework/v405.9.1/Tor.podspec`) - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) @@ -1839,6 +1841,7 @@ SPEC REPOS: - SDWebImageAVIFCoder - SDWebImageWebPCoder - SocketRocket + - Sodium - SSZipArchive EXTERNAL SOURCES: @@ -2114,10 +2117,11 @@ SPEC CHECKSUMS: SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + Sodium: 23d11554ecd556196d313cf6130d406dfe7ac6da SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea Tor: 39dc71bf048312e202608eb499ca5c74e841b503 Yoga: 1a10502f162e8fc40ff0907c82d01cfe555d00c2 -PODFILE CHECKSUM: 1f3635b9a52a9f3267675c779818f1eab5bb5304 +PODFILE CHECKSUM: cfc64e6e7d6e469da4bad48f367062c5e329239f COCOAPODS: 1.16.2 diff --git a/packages/mobile/ios/Quiet.debug.xcconfig b/packages/mobile/ios/Quiet.debug.xcconfig new file mode 100644 index 0000000000..a876e8f4b5 --- /dev/null +++ b/packages/mobile/ios/Quiet.debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig" +#include "Quiet.xcconfig" diff --git a/packages/mobile/ios/Quiet.release.xcconfig b/packages/mobile/ios/Quiet.release.xcconfig new file mode 100644 index 0000000000..2ada3273b9 --- /dev/null +++ b/packages/mobile/ios/Quiet.release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig" +#include "Quiet.xcconfig" diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 4628b90a75..682d61bcd2 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -55,11 +55,12 @@ 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A37296F009E00A2B8C0 /* AppDelegate.m */; }; 18FD2A3F296F009E00A2B8C0 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 18FD2A38296F009E00A2B8C0 /* Images.xcassets */; }; 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; - 2BDF48C971BAFE479E950BAE /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */; }; + 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */; }; 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; }; 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; }; - 71C06A74FB351AA5D0449823 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BD713118E20F543EF89A5669 /* Pods_Quiet.framework */; }; + 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; + CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */; }; D3239FB5EFA85E780E1AD201 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */; }; EB4DC7EC2F5FF6AB00EFD23F /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */; }; EB4DC8002F608A3300EFD23F /* QuietNotificationServiceExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -132,8 +133,9 @@ 03B674042E6103DC00A86655 /* Rubik-Regular.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-Regular.ttf"; path = "../src/assets/fonts/Rubik-Regular.ttf"; sourceTree = SOURCE_ROOT; }; 03B674052E6103DC00A86655 /* Rubik-SemiBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBold.ttf"; path = "../src/assets/fonts/Rubik-SemiBold.ttf"; sourceTree = SOURCE_ROOT; }; 03B674062E6103DC00A86655 /* Rubik-SemiBoldItalic.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = "Rubik-SemiBoldItalic.ttf"; path = "../src/assets/fonts/Rubik-SemiBoldItalic.ttf"; sourceTree = SOURCE_ROOT; }; - 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; + 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.release.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.release.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* Quiet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Quiet.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_QuietNotificationServiceExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 180E120A2AEFB7F900804659 /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 1827A9E129783D6E00245FD3 /* classic-level.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = "classic-level.framework"; sourceTree = ""; }; 183C484F296C7B6700BA2D8B /* v8-platform.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "v8-platform.h"; sourceTree = ""; }; @@ -643,22 +645,28 @@ 18FD2A39296F009E00A2B8C0 /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Quiet/main.m; sourceTree = ""; }; 18FD2A3A296F009E00A2B8C0 /* Quiet.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = Quiet.entitlements; path = Quiet/Quiet.entitlements; sourceTree = ""; }; 18FD2A3B296F009E00A2B8C0 /* QuietDebug.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; name = QuietDebug.entitlements; path = Quiet/QuietDebug.entitlements; sourceTree = ""; }; - 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; + 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; }; - 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; - 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; + 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; + 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; 955DC7572BD930B30014725B /* WebsocketSingleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketSingleton.swift; sourceTree = ""; }; - BD713118E20F543EF89A5669 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Quiet.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; }; - E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.debug.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig"; sourceTree = ""; }; + CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-QuietNotificationServiceExtension.release.xcconfig"; path = "Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig"; sourceTree = ""; }; + DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; }; + DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; + E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.debug.xcconfig; sourceTree = SOURCE_ROOT; }; + E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.debug.xcconfig; sourceTree = SOURCE_ROOT; }; + E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.release.xcconfig; sourceTree = SOURCE_ROOT; }; + E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; }; EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuietNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; EB4DC8152F60912900EFD23F /* AppDelegate+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "AppDelegate+Firebase.swift"; path = "Quiet/AppDelegate+Firebase.swift"; sourceTree = ""; }; EB5F92FA2F5FBB2100B5C60D /* FirebaseMessagingModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = FirebaseMessagingModule.m; path = Quiet/FirebaseMessagingModule.m; sourceTree = ""; }; EB5F92FB2F5FBB2100B5C60D /* FirebaseMessagingModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FirebaseMessagingModule.swift; path = Quiet/FirebaseMessagingModule.swift; sourceTree = ""; }; EB5F93032F5FC50100B5C60D /* Quiet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Quiet-Bridging-Header.h"; sourceTree = ""; }; - FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -700,7 +708,7 @@ buildActionMask = 2147483647; files = ( 1827A9E229783D6E00245FD3 /* classic-level.framework in Frameworks */, - 2BDF48C971BAFE479E950BAE /* Pods_Quiet_QuietTests.framework in Frameworks */, + 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -710,7 +718,7 @@ files = ( 00A416342EC2EAA900ACC877 /* NodeMobile.xcframework in Frameworks */, 1827A9E329783D7600245FD3 /* classic-level.framework in Frameworks */, - 71C06A74FB351AA5D0449823 /* Pods_Quiet.framework in Frameworks */, + CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -718,6 +726,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4781,10 +4790,12 @@ 1CEEDB4F07B9978C125775C5 /* Pods */ = { isa = PBXGroup; children = ( - E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */, - 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */, - 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */, - 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */, + DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */, + 09783DD98C14F076599A13B4 /* Pods-Quiet.release.xcconfig */, + 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */, + 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */, + C5124A51F9229F2CDC5100C0 /* Pods-QuietNotificationServiceExtension.debug.xcconfig */, + CDD5E3F75BDB63D6BC806F2B /* Pods-QuietNotificationServiceExtension.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -4794,8 +4805,9 @@ children = ( 00A416332EC2EAA900ACC877 /* NodeMobile.xcframework */, 1827A9E129783D6E00245FD3 /* classic-level.framework */, - BD713118E20F543EF89A5669 /* Pods_Quiet.framework */, - FFC10A63B3F19389C478C448 /* Pods_Quiet_QuietTests.framework */, + 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */, + 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */, + 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */, ); name = Frameworks; sourceTree = ""; @@ -4841,6 +4853,10 @@ 624523FCC5994B7E9869E9CF /* Resources */, 1CEEDB4F07B9978C125775C5 /* Pods */, DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */, + E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */, + E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */, + E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */, + E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */, ); indentWidth = 2; sourceTree = ""; @@ -4864,12 +4880,12 @@ isa = PBXNativeTarget; buildConfigurationList = 00E357021AD99517003FC87E /* Build configuration list for PBXNativeTarget "QuietTests" */; buildPhases = ( - 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */, + B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */, 00E356EA1AD99517003FC87E /* Sources */, 00E356EB1AD99517003FC87E /* Frameworks */, 00E356EC1AD99517003FC87E /* Resources */, - EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */, - 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */, + C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */, + D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4885,7 +4901,7 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Quiet" */; buildPhases = ( - 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */, + 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */, FD10A7F022414F080027D42C /* Start Packager */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, @@ -4897,9 +4913,9 @@ 18FD2A32296D736300A2B8C0 /* [CUSTOM NODEJS MOBILE] Remove Python3 Binaries */, 1827A9E0297837FE00245FD3 /* [CUSTOM NODEJS MOBILE] Remove prebuilds */, 1868C095292F8FE2001D6D5E /* Embed Frameworks */, - 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */, - A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */, EBD3273E2F5FA01F00E2CD0C /* Embed Foundation Extensions */, + 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */, + B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -4915,6 +4931,7 @@ isa = PBXNativeTarget; buildConfigurationList = EB4DC8022F608A3300EFD23F /* Build configuration list for PBXNativeTarget "QuietNotificationServiceExtension" */; buildPhases = ( + 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */, EB4DC7F52F608A3300EFD23F /* Sources */, EB4DC7F62F608A3300EFD23F /* Frameworks */, EB4DC7F72F608A3300EFD23F /* Resources */, @@ -5140,46 +5157,46 @@ shellPath = /bin/sh; shellScript = "find \"$CODESIGNING_FOLDER_PATH/nodejs-project/node_modules/\" -name \"python3\" | xargs rm\n"; }; - 4E18C6AEAD8D524EAD836F51 /* [CP] Copy Pods Resources */ = { + 3D09985B32EEEAFD930419CB /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-QuietNotificationServiceExtension-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 58352AE7BD90BC0845FC78DE /* [CP] Check Pods Manifest.lock */ = { + 468C008BFE8FDD32EC649A5B /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 715B735C8E7FA602A967EAF0 /* [CP] Check Pods Manifest.lock */ = { + 669A6891CB9F7ADDDD9A98B4 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5201,41 +5218,46 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 7253A52F091FC8122D730B2E /* [CP] Embed Pods Frameworks */ = { + B04C9103A39CF5F564636327 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; showEnvVarsInLog = 0; }; - A1AE2657AE5C40D18941D671 /* [CP] Copy Pods Resources */ = { + B84DABF150D7B1B5C2DF9239 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Quiet-QuietTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet/Pods-Quiet-resources.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - EF4E9879B3A5060ADF995D6A /* [CP] Embed Pods Frameworks */ = { + C0311D831E5FCA078E3114A7 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -5252,6 +5274,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + D779ACFAE5667A522EFCF468 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; FD10A7F022414F080027D42C /* Start Packager */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -5332,7 +5371,7 @@ /* Begin XCBuildConfiguration section */ 00E356F61AD99517003FC87E /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7F75CE6D1C7D3DA9F5BA6A76 /* Pods-Quiet-QuietTests.debug.xcconfig */; + baseConfigurationReference = 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -5367,7 +5406,7 @@ }; 00E356F71AD99517003FC87E /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 2877B6088D1D81EF869D71D9 /* Pods-Quiet-QuietTests.release.xcconfig */; + baseConfigurationReference = 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ENABLE_MODULES = YES; @@ -5398,7 +5437,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E727FE0446E9E46CDECEC7FE /* Pods-Quiet.debug.xcconfig */; + baseConfigurationReference = E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -5498,7 +5537,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 116749B7D5552021F04BE739 /* Pods-Quiet.release.xcconfig */; + baseConfigurationReference = E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */; buildSettings = { ARCHS = "$(ARCHS_STANDARD)"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; @@ -5762,12 +5801,11 @@ }; EB4DC8032F608A3300EFD23F /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */; + baseConfigurationReference = E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; @@ -5808,12 +5846,11 @@ }; EB4DC8042F608A3300EFD23F /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */; + baseConfigurationReference = E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 7fe7a0704b..cc0241871a 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -66,6 +66,8 @@ NSPhotoLibraryUsageDescription Quiet access photos for sending images through the app. + QuietKeychainAccessGroup + $(DEVELOPMENT_TEAM).com.quietmobile UIAppFonts Rubik-Black.ttf diff --git a/packages/mobile/ios/Quiet/Quiet.entitlements b/packages/mobile/ios/Quiet/Quiet.entitlements index cdd4095f13..8dfe8e3e84 100644 --- a/packages/mobile/ios/Quiet/Quiet.entitlements +++ b/packages/mobile/ios/Quiet/Quiet.entitlements @@ -5,7 +5,9 @@ aps-environment development com.apple.security.application-groups - + + group.com.quietmobile + keychain-access-groups $(AppIdentifierPrefix)com.quietmobile diff --git a/packages/mobile/ios/Quiet/QuietDebug.entitlements b/packages/mobile/ios/Quiet/QuietDebug.entitlements index cdd4095f13..8dfe8e3e84 100644 --- a/packages/mobile/ios/Quiet/QuietDebug.entitlements +++ b/packages/mobile/ios/Quiet/QuietDebug.entitlements @@ -5,7 +5,9 @@ aps-environment development com.apple.security.application-groups - + + group.com.quietmobile + keychain-access-groups $(AppIdentifierPrefix)com.quietmobile diff --git a/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig b/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig new file mode 100644 index 0000000000..ae188f8dea --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension.debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.debug.xcconfig" +#include "Quiet.xcconfig" diff --git a/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig b/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig new file mode 100644 index 0000000000..1dfbc9ee5d --- /dev/null +++ b/packages/mobile/ios/QuietNotificationServiceExtension.release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-QuietNotificationServiceExtension/Pods-QuietNotificationServiceExtension.release.xcconfig" +#include "Quiet.xcconfig" diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 7c89804a15..071d5d072d 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -22,6 +22,8 @@ 1 FirebaseAppDelegateProxyEnabled + QuietKeychainAccessGroup + $(DEVELOPMENT_TEAM).com.quietmobile NSExtension NSExtensionPointIdentifier diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift index 5ab93d9b64..261c0bfce3 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift @@ -1,4 +1,7 @@ import Foundation +import os.log + +private let authLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSEAuthService") class NSEAuthService { private let client: NSENetworkClient @@ -15,20 +18,29 @@ class NSEAuthService { func authenticate(deviceId: String, teamId: String) async throws -> String { if let cached = tokenCache[teamId], cached.expiry > Date() { + os_log("authenticate: using cached token for teamId=%{public}@, expires=%{public}@", + log: authLog, type: .debug, teamId, "\(cached.expiry)") return cached.token } + os_log("authenticate: requesting challenge for deviceId=%{public}@ teamId=%{public}@", + log: authLog, type: .info, deviceId, teamId) let challengeResp = try await client.requestChallenge(deviceId: deviceId, teamId: teamId) + os_log("authenticate: got challengeId=%{public}@", log: authLog, type: .debug, challengeResp.challengeId) - // Sign the challenge using msgpack serialization (matching TypeScript identity.prove()) + os_log("authenticate: reading device private key from keychain", log: authLog, type: .debug) let privateKeyData = try NSEKeychainHelper.getDevicePrivateKey(deviceId: deviceId) + os_log("authenticate: private key read (%{public}d bytes), signing challenge", log: authLog, type: .debug, privateKeyData.count) + let proof = try crypto.signChallengePayload(challengeResp.challenge, privateKeyData: privateKeyData) + os_log("authenticate: signed challenge, requesting token", log: authLog, type: .debug) let tokenResp = try await client.requestToken( challengeId: challengeResp.challengeId, deviceId: deviceId, proof: proof ) + os_log("authenticate: token received, expiresIn=%{public}d", log: authLog, type: .info, tokenResp.expiresIn) tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30)) @@ -38,9 +50,13 @@ class NSEAuthService { // MARK: - Fetch log entries func fetchNewEntries(teamId: String, since: Int64) async throws -> [LogEntry] { + os_log("fetchNewEntries: reading deviceId from keychain", log: authLog, type: .debug) let deviceId = try NSEKeychainHelper.getDeviceId() + os_log("fetchNewEntries: deviceId=%{public}@, authenticating", log: authLog, type: .info, deviceId) let token = try await authenticate(deviceId: deviceId, teamId: teamId) + os_log("fetchNewEntries: authenticated, fetching log entries since=%{public}lld", log: authLog, type: .info, since) let resp = try await client.fetchLogEntries(teamId: teamId, since: since, token: token) + os_log("fetchNewEntries: received %{public}d entries", log: authLog, type: .info, resp.entries.count) return resp.entries } } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift index 8f1b12ff39..e2dfbae959 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -1,5 +1,26 @@ import CryptoKit import Foundation +import Sodium + +private typealias NSEJSONObject = [String: Any] + +struct NSEDecryptedNotificationMessage { + let channelId: String + let userId: String + let body: String + let type: Int +} + +private struct NSEEncryptionScope { + let type: String + let name: String + let generation: Int +} + +private struct NSEEncryptedPayload { + let contents: Data + let scope: NSEEncryptionScope +} // MARK: - Protocol @@ -10,6 +31,9 @@ protocol DeviceCryptography { /// Signs a challenge payload exactly as `identity.prove()` does in TypeScript. func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload + + /// Decrypts a QSS log entry and, if it is a channel message, returns a displayable preview. + func decryptNotificationMessage(from logEntry: LogEntry, teamId: String) throws -> NSEDecryptedNotificationMessage? } extension DeviceCryptography { @@ -43,12 +67,18 @@ extension DeviceCryptography { enum NSECryptoError: Error, LocalizedError { case invalidKeyLength(expected: Int, got: Int) case invalidBase58 + case invalidPayload(String) + case msgpack(String) + case decryptionFailed(String) case signingFailed var errorDescription: String? { switch self { case .invalidKeyLength(let e, let g): return "Invalid key length: expected \(e), got \(g)" case .invalidBase58: return "Invalid base58 encoding" + case .invalidPayload(let msg): return "Invalid payload: \(msg)" + case .msgpack(let msg): return "MessagePack decoding failed: \(msg)" + case .decryptionFailed(let msg): return "Decryption failed: \(msg)" case .signingFailed: return "Signing failed" } } @@ -56,22 +86,247 @@ enum NSECryptoError: Error, LocalizedError { // MARK: - NSECryptoService -/// Mirrors `@localfirst/crypto` signing used in `identity.prove()`: -/// 1. msgpack-serialize the challenge object (same encoding as msgpackr) -/// 2. Sign with `crypto_sign_detached` (Ed25519, libsodium 64-byte key) -/// 3. base58-encode the 64-byte signature +/// Mirrors the JS crypto stack used in Quiet: +/// 1. Challenge signing matches `msgpackr.pack()` + `crypto_sign_detached` +/// 2. Log-entry decryption matches `@localfirst/crypto` symmetric.decryptBytes() +/// 3. QSS log entries contain msgpackr-record-encoded payloads, not JSON class NSECryptoService: DeviceCryptography { + private let sodium = Sodium() + + // Matches @localfirst/crypto stretch.ts + private static let stretchSalt: [UInt8] = { + guard let salt = Base58.decode("H5B4DLSXw5xwNYFdz1Wr6e") else { return [] } + return [UInt8](salt) + }() /// Signs raw bytes using the device Ed25519 private key. /// The 64-byte libsodium secret key = 32-byte seed ++ 32-byte public key. /// CryptoKit only needs the 32-byte seed. func sign(message: Data) throws -> Data { - // Requires private key externally; use signBytes(_:privateKeyData:) directly. throw NSECryptoError.signingFailed } + + func decryptNotificationMessage(from logEntry: LogEntry, teamId: String) throws -> NSEDecryptedNotificationMessage? { + let outerEnvelope = try self.decodeObject(logEntry.entry) + guard let outerDict = outerEnvelope as? NSEJSONObject else { + return nil + } + let outerEncrypted = try self.parseEncryptedPayload(outerDict["encrypted"], label: "outer QSS payload") + + let orbitEntry = try self.decryptPayload(outerEncrypted, teamId: teamId) + guard + let orbitEntryDict = orbitEntry as? NSEJSONObject, + let payload = orbitEntryDict["payload"] as? NSEJSONObject, + let payloadValue = payload["value"] as? NSEJSONObject + else { + return nil + } + + guard + payloadValue["contents"] != nil, + payloadValue["channelId"] != nil + else { + return nil + } + + let innerEncrypted = try self.parseEncryptedPayload(payloadValue["contents"], label: "inner channel message") + let decryptedInner = try self.decryptPayload(innerEncrypted, teamId: teamId) + guard let message = decryptedInner as? NSEJSONObject else { + return nil + } + + guard + let channelId = self.stringValue(message["channelId"]), + let userId = self.stringValue(message["userId"]), + let type = self.intValue(message["type"]), + let body = self.notificationBody(from: message, type: type) + else { + return nil + } + + return NSEDecryptedNotificationMessage( + channelId: channelId, + userId: userId, + body: body, + type: type + ) + } + + private func notificationBody(from message: NSEJSONObject, type: Int) -> String? { + let trimmed = (self.stringValue(message["message"]) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if !trimmed.isEmpty { + return trimmed + } + + switch type { + case 2: + return "Sent an image" + case 4: + return "Sent a file" + default: + return nil + } + } + + private func decryptPayload(_ encryptedPayload: NSEEncryptedPayload, teamId: String) throws -> Any { + let keyName = self.makeKeyName(teamId: teamId, scope: encryptedPayload.scope) + let secretKey = try NSEKeychainHelper.getLfaKeyString(keyName: keyName) + return try self.decryptSymmetric(cipherBytes: encryptedPayload.contents, password: secretKey) + } + + private func decryptSymmetric(cipherBytes: Data, password: String) throws -> Any { + let cipher = try self.decodeObject(cipherBytes) + guard let cipherDict = cipher as? NSEJSONObject else { + throw NSECryptoError.invalidPayload("cipher bytes did not decode to an object") + } + + guard + let nonce = self.dataValue(cipherDict["nonce"]), + let message = self.dataValue(cipherDict["message"]), + let tag = self.dataValue(cipherDict["tag"]), + let mac = self.dataValue(cipherDict["mac"]) + else { + throw NSECryptoError.invalidPayload("cipher object was missing nonce/message/tag/mac") + } + + let derivedKey = try self.stretch(password) + let authMessage = [UInt8](nonce + mac) + let tagValid = self.sodium.auth.verify( + message: authMessage, + secretKey: derivedKey, + tag: [UInt8](tag) + ) + guard tagValid else { + throw NSECryptoError.decryptionFailed("cipher tag verification failed") + } + + guard let decryptedBytes = self.sodium.secretBox.open( + cipherText: [UInt8](message), + secretKey: derivedKey, + nonce: [UInt8](nonce), + mac: [UInt8](mac) + ) else { + throw NSECryptoError.decryptionFailed("secretbox open failed") + } + + return try self.decodeObject(Data(decryptedBytes)) + } + + private func stretch(_ password: String) throws -> [UInt8] { + let passwordBytes = [UInt8](password.utf8) + guard !Self.stretchSalt.isEmpty else { + throw NSECryptoError.invalidBase58 + } + + if passwordBytes.count >= 16 { + guard let derived = self.sodium.genericHash.hash( + message: passwordBytes, + key: Self.stretchSalt, + outputLength: 32 + ) else { + throw NSECryptoError.decryptionFailed("generic hash stretch failed") + } + return derived + } + + guard let derived = self.sodium.pwHash.hash( + outputLength: 32, + passwd: passwordBytes, + salt: Self.stretchSalt, + opsLimit: self.sodium.pwHash.OpsLimitInteractive, + memLimit: self.sodium.pwHash.MemLimitInteractive + ) else { + throw NSECryptoError.decryptionFailed("argon2 stretch failed") + } + return derived + } + + private func parseEncryptedPayload(_ value: Any?, label: String) throws -> NSEEncryptedPayload { + guard let dict = value as? NSEJSONObject else { + throw NSECryptoError.invalidPayload("\(label) was not an object") + } + + guard let contents = self.dataValue(dict["contents"]) else { + throw NSECryptoError.invalidPayload("\(label) contents were not binary") + } + + guard + let scopeDict = dict["scope"] as? NSEJSONObject, + let scopeType = self.stringValue(scopeDict["type"]), + let scopeName = self.stringValue(scopeDict["name"]), + let generation = self.intValue(scopeDict["generation"]) + else { + throw NSECryptoError.invalidPayload("\(label) scope was malformed") + } + + return NSEEncryptedPayload( + contents: contents, + scope: NSEEncryptionScope(type: scopeType, name: scopeName, generation: generation) + ) + } + + private func makeKeyName(teamId: String, scope: NSEEncryptionScope) -> String { + return "quiet_\(teamId)_\(scope.type)_\(scope.name)_\(scope.generation)_secret" + } + + private func decodeObject(_ data: Data) throws -> Any { + return try NSEMsgpack.decode(data) + } + + private func dataValue(_ value: Any?) -> Data? { + if let data = value as? Data { + return data + } + if let bytes = value as? [UInt8] { + return Data(bytes) + } + return nil + } + + private func stringValue(_ value: Any?) -> String? { + if let string = value as? String { + return string + } + return nil + } + + private func intValue(_ value: Any?) -> Int? { + switch value { + case let int as Int: + return int + case let int8 as Int8: + return Int(int8) + case let int16 as Int16: + return Int(int16) + case let int32 as Int32: + return Int(int32) + case let int64 as Int64: + return Int(int64) + case let uint as UInt: + return Int(uint) + case let uint8 as UInt8: + return Int(uint8) + case let uint16 as UInt16: + return Int(uint16) + case let uint32 as UInt32: + return Int(uint32) + case let uint64 as UInt64: + return Int(uint64) + case let number as NSNumber: + return number.intValue + case let double as Double: + return Int(double) + case let string as String: + return Int(string) + default: + return nil + } + } } -// MARK: - Msgpack encoder +// MARK: - Msgpack helpers // Encodes the ChallengePayload object in the same byte format as msgpackr.pack(): // { type: string, name: string, nonce: string, timestamp: number } // @@ -82,7 +337,15 @@ class NSECryptoService: DeviceCryptography { // Field order must exactly match the JS object insertion order. enum NSEMsgpack { - enum MsgpackError: Error { case unsupportedType, stringTooLong } + enum MsgpackError: Error { + case invalidRecordDefinition + case invalidString + case invalidMapKey + case stringTooLong + case truncated + case unsupportedExtension(UInt8) + case unsupportedType(UInt8) + } /// Encodes a ChallengePayload in the same byte format as msgpackr.pack(). static func encode(_ challenge: ChallengePayload) throws -> Data { @@ -109,8 +372,12 @@ enum NSEMsgpack { return out } + static func decode(_ data: Data) throws -> Any { + try Decoder(data: data).decode() + } + private static func appendString(_ s: String, to out: inout Data) throws { - guard let bytes = s.data(using: .utf8) else { throw MsgpackError.unsupportedType } + guard let bytes = s.data(using: .utf8) else { throw MsgpackError.invalidString } let len = bytes.count if len < 32 { // fixstr: 0xa0 | len @@ -144,4 +411,275 @@ enum NSEMsgpack { out.append(UInt8((bits >> 8) & 0xFF)) out.append(UInt8(bits & 0xFF)) } + + private final class Decoder { + private let bytes: [UInt8] + private var index: Int = 0 + private var records: [UInt8: [String]] = [:] + + init(data: Data) { + self.bytes = [UInt8](data) + } + + func decode() throws -> Any { + let value = try self.readValue() + guard self.index == self.bytes.count else { + throw MsgpackError.truncated + } + return value + } + + private func readValue() throws -> Any { + let token = try self.readByte() + + switch token { + case 0x00...0x3f: + return Int(token) + case 0x40...0x7f: + if let record = self.records[token] { + return try self.readRecord(record) + } + return Int(token) + case 0x80...0x8f: + return try self.readMap(count: Int(token & 0x0f)) + case 0x90...0x9f: + return try self.readArray(count: Int(token & 0x0f)) + case 0xa0...0xbf: + return try self.readString(length: Int(token & 0x1f)) + case 0xc0: + return NSNull() + case 0xc1: + throw MsgpackError.unsupportedType(token) + case 0xc2: + return false + case 0xc3: + return true + case 0xc4: + return try self.readBinary(length: Int(try self.readByte())) + case 0xc5: + return try self.readBinary(length: Int(try self.readUInt16())) + case 0xc6: + return try self.readBinary(length: Int(try self.readUInt32())) + case 0xc7: + return try self.readExtension(length: Int(try self.readByte())) + case 0xc8: + return try self.readExtension(length: Int(try self.readUInt16())) + case 0xc9: + return try self.readExtension(length: Int(try self.readUInt32())) + case 0xca: + return try self.readFloat32() + case 0xcb: + return try self.readFloat64() + case 0xcc: + return Int(try self.readByte()) + case 0xcd: + return Int(try self.readUInt16()) + case 0xce: + return Int(try self.readUInt32()) + case 0xcf: + let value = try self.readUInt64() + return value <= UInt64(Int.max) ? Int(value) : Double(value) + case 0xd0: + return Int(try self.readInt8()) + case 0xd1: + return Int(try self.readInt16()) + case 0xd2: + return Int(try self.readInt32()) + case 0xd3: + let value = try self.readInt64() + return value >= Int64(Int.min) && value <= Int64(Int.max) ? Int(value) : Double(value) + case 0xd4: + return try self.readFixext(length: 1) + case 0xd5: + return try self.readFixext(length: 2) + case 0xd6: + return try self.readFixext(length: 4) + case 0xd7: + return try self.readFixext(length: 8) + case 0xd8: + return try self.readFixext(length: 16) + case 0xd9: + return try self.readString(length: Int(try self.readByte())) + case 0xda: + return try self.readString(length: Int(try self.readUInt16())) + case 0xdb: + return try self.readString(length: Int(try self.readUInt32())) + case 0xdc: + return try self.readArray(count: Int(try self.readUInt16())) + case 0xdd: + return try self.readArray(count: Int(try self.readUInt32())) + case 0xde: + return try self.readMap(count: Int(try self.readUInt16())) + case 0xdf: + return try self.readMap(count: Int(try self.readUInt32())) + case 0xe0...0xff: + return Int(Int8(bitPattern: token)) + default: + throw MsgpackError.unsupportedType(token) + } + } + + private func readRecord(_ keys: [String]) throws -> NSEJSONObject { + var object: NSEJSONObject = [:] + object.reserveCapacity(keys.count) + for key in keys { + object[key] = try self.readValue() + } + return object + } + + private func readMap(count: Int) throws -> NSEJSONObject { + var map: NSEJSONObject = [:] + map.reserveCapacity(count) + for _ in 0.. [Any] { + var array: [Any] = [] + array.reserveCapacity(count) + for _ in 0.. String { + let data = try self.readData(length: length) + guard let string = String(data: data, encoding: .utf8) else { + throw MsgpackError.invalidString + } + return string + } + + private func readBinary(length: Int) throws -> Data { + return try self.readData(length: length) + } + + private func readExtension(length: Int) throws -> Any { + let type = try self.readByte() + let payload = try self.readData(length: length) + if type == 0x72, length == 1 { + guard let recordId = payload.first else { + throw MsgpackError.invalidRecordDefinition + } + let keysValue = try self.readValue() + guard let keys = keysValue as? [Any] else { + throw MsgpackError.invalidRecordDefinition + } + let stringKeys = try keys.map { key -> String in + guard let key = key as? String else { + throw MsgpackError.invalidRecordDefinition + } + return key + } + self.records[recordId] = stringKeys + return try self.readRecord(stringKeys) + } + return payload + } + + private func readFixext(length: Int) throws -> Any { + let type = try self.readByte() + let payload = try self.readData(length: length) + if type == 0x72, length == 1 { + guard let recordId = payload.first else { + throw MsgpackError.invalidRecordDefinition + } + let keysValue = try self.readValue() + guard let keys = keysValue as? [Any] else { + throw MsgpackError.invalidRecordDefinition + } + let stringKeys = try keys.map { key -> String in + guard let key = key as? String else { + throw MsgpackError.invalidRecordDefinition + } + return key + } + self.records[recordId] = stringKeys + return try self.readRecord(stringKeys) + } + // msgpackr uses fixext1 type 0 data 0 for undefined. Treat it as nil-like. + if type == 0x00, length == 1, payload.first == 0x00 { + return NSNull() + } + return payload + } + + private func readData(length: Int) throws -> Data { + guard self.index + length <= self.bytes.count else { + throw MsgpackError.truncated + } + let data = Data(self.bytes[self.index..<(self.index + length)]) + self.index += length + return data + } + + private func readByte() throws -> UInt8 { + guard self.index < self.bytes.count else { + throw MsgpackError.truncated + } + let value = self.bytes[self.index] + self.index += 1 + return value + } + + private func readUInt16() throws -> UInt16 { + let b0 = UInt16(try self.readByte()) + let b1 = UInt16(try self.readByte()) + return (b0 << 8) | b1 + } + + private func readUInt32() throws -> UInt32 { + let b0 = UInt32(try self.readByte()) + let b1 = UInt32(try self.readByte()) + let b2 = UInt32(try self.readByte()) + let b3 = UInt32(try self.readByte()) + return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3 + } + + private func readUInt64() throws -> UInt64 { + let b0 = UInt64(try self.readByte()) + let b1 = UInt64(try self.readByte()) + let b2 = UInt64(try self.readByte()) + let b3 = UInt64(try self.readByte()) + let b4 = UInt64(try self.readByte()) + let b5 = UInt64(try self.readByte()) + let b6 = UInt64(try self.readByte()) + let b7 = UInt64(try self.readByte()) + return (b0 << 56) | (b1 << 48) | (b2 << 40) | (b3 << 32) | (b4 << 24) | (b5 << 16) | (b6 << 8) | b7 + } + + private func readInt8() throws -> Int8 { + Int8(bitPattern: try self.readByte()) + } + + private func readInt16() throws -> Int16 { + Int16(bitPattern: try self.readUInt16()) + } + + private func readInt32() throws -> Int32 { + Int32(bitPattern: try self.readUInt32()) + } + + private func readInt64() throws -> Int64 { + Int64(bitPattern: try self.readUInt64()) + } + + private func readFloat32() throws -> Double { + let bits = try self.readUInt32() + return Double(Float(bitPattern: bits)) + } + + private func readFloat64() throws -> Double { + let bits = try self.readUInt64() + return Double(bitPattern: bits) + } + } } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index 6acc12302f..d3e20f42c5 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -9,6 +9,7 @@ struct NSEKeychainHelper { private static let deviceIdKey = "quiet.device.id" private static let teamIdKey = "quiet.team.id" private static let lastSyncKey = "quiet.nse.lastSyncTimestamp" + private static let lfaKeyService = "com.quietmobile" // MARK: - Device private key @@ -43,6 +44,14 @@ struct NSEKeychainHelper { return str } + static func getLfaKeyString(keyName: String) throws -> String { + let data = try readData(account: keyName, label: "LFA key", service: lfaKeyService) + guard let str = String(data: data, encoding: .utf8) else { + throw NSEAuthError.keychainError("LFA key '\(keyName)' is not valid UTF-8") + } + return str + } + // MARK: - Last sync timestamp (UserDefaults — not sensitive) static func getLastSyncTimestamp() -> Int64 { @@ -57,21 +66,25 @@ struct NSEKeychainHelper { // MARK: - Private helpers - // Must match the App Group entitlement in both the main app and NSE targets - // (Signing & Capabilities → App Groups → group.com.quietmobile). - private static let accessGroup = "group.com.quietmobile" + // Must match the shared keychain entitlement in both the main app and NSE targets. + private static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String - private static func readData(account: String, label: String) throws -> Data { + private static func readData(account: String, label: String, service: String? = nil) throws -> Data { // Note: kSecAttrAccessible is intentionally omitted — it's a write attribute. // Including it in a read query can cause silent failures on some iOS versions. // Accessibility is enforced at write time (kSecAttrAccessibleAfterFirstUnlock). - let query: [CFString: Any] = [ + var query: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: account, - kSecAttrAccessGroup: accessGroup, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] + if let accessGroup { + query[kSecAttrAccessGroup] = accessGroup + } + if let service { + query[kSecAttrService] = service + } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift index 5ee3af1ea4..2221257c78 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift @@ -46,6 +46,7 @@ struct ChallengeResponse: Codable { struct ProofPayload: Codable { let signature: String + let publicKey: String } struct TokenRequest: Codable { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift index e946130e6d..f3b1445113 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift @@ -1,4 +1,7 @@ import Foundation +import os.log + +private let netLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSENetworkClient") class NSENetworkClient { let baseURL: URL @@ -86,18 +89,28 @@ class NSENetworkClient { as type: T.Type, onError: (Int) throws -> Void ) async throws -> T { + let method = request.httpMethod ?? "GET" + let urlStr = request.url?.absoluteString ?? "(nil)" + os_log("perform: %{public}@ %{public}@", log: netLog, type: .debug, method, urlStr) + let (data, response): (Data, URLResponse) do { (data, response) = try await session.data(for: request) } catch { + os_log("perform: network error for %{public}@: %{public}@", log: netLog, type: .error, urlStr, String(describing: error)) throw NSEAuthError.networkError(error) } guard let http = response as? HTTPURLResponse else { + os_log("perform: non-HTTP response for %{public}@", log: netLog, type: .error, urlStr) throw NSEAuthError.invalidResponse } + os_log("perform: %{public}@ %{public}@ → HTTP %{public}d", log: netLog, type: .info, method, urlStr, http.statusCode) + guard (200..<300).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)" + os_log("perform: error body: %{public}@", log: netLog, type: .error, body) try onError(http.statusCode) throw NSEAuthError.invalidResponse // unreachable; onError always throws } @@ -105,6 +118,9 @@ class NSENetworkClient { do { return try Self.decoder.decode(T.self, from: data) } catch { + let body = String(data: data, encoding: .utf8) ?? "(non-UTF8 body)" + os_log("perform: decoding failed for %{public}@: %{public}@\nresponse body: %{public}@", + log: netLog, type: .error, String(describing: T.self), String(describing: error), body) throw NSEAuthError.decodingFailed(error) } } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 4110776833..80b2e05d4f 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -122,7 +122,45 @@ class NotificationService: UNNotificationServiceExtension { os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error) return } - let newBadge = (content.badge?.intValue ?? 0) + entries.count + + let decryptedMessages = entries.compactMap { entry -> NSEDecryptedNotificationMessage? in + do { + return try self.crypto.decryptNotificationMessage(from: entry, teamId: teamId) + } catch { + os_log( + "fetchAndUpdate: failed to decrypt entry %{public}@: %{public}@", + log: nseLog, + type: .error, + entry.cid, + String(describing: error) + ) + return nil + } + } + + if let latestMessage = decryptedMessages.last { + let title = content.title.trimmingCharacters(in: .whitespacesAndNewlines) + content.title = title.isEmpty ? "Quiet" : title + content.body = latestMessage.body + + if decryptedMessages.count > 1 { + content.subtitle = "\(decryptedMessages.count) new messages" + } else { + content.subtitle = "" + } + + os_log( + "fetchAndUpdate: updated notification body from decrypted message (count=%{public}d)", + log: nseLog, + type: .info, + decryptedMessages.count + ) + } else { + os_log("fetchAndUpdate: no decryptable channel messages found", log: nseLog, type: .info) + } + + let badgeIncrement = decryptedMessages.isEmpty ? entries.count : decryptedMessages.count + let newBadge = (content.badge?.intValue ?? 0) + badgeIncrement os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) content.badge = newBadge as NSNumber } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements b/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements index 2eb7e333a6..8570a5737d 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements +++ b/packages/mobile/ios/QuietNotificationServiceExtension/QuietNotificationServiceExtension.entitlements @@ -3,6 +3,12 @@ com.apple.security.application-groups - + + group.com.quietmobile + + keychain-access-groups + + $(AppIdentifierPrefix)com.quietmobile + diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 4c12d91f62..8f89626a39 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -16,7 +16,7 @@ import { PayloadAction } from '@reduxjs/toolkit' import { socket as stateManager, Socket } from '@quiet/state-manager' import { initActions, WebsocketConnectionPayload } from '../init.slice' import { eventChannel } from 'redux-saga' -import { KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types' import { createLogger } from '../../../utils/logger' import { initSelectors } from '../init.selectors' import { keysActions } from '../../keys/keys.slice' @@ -81,6 +81,7 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect | ReturnType | ReturnType | ReturnType + | ReturnType | ReturnType >(emit => { socket.on('connect', async () => { @@ -96,6 +97,10 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.info('Keys updated, writing to keychain') emit(keysActions.saveKeysInKeychain(payload)) }) + socket.on(SocketEvents.DEVICE_CREDENTIALS_UPDATED, async (payload: DeviceCredentialsUpdatedEvent) => { + logger.info('Device credentials updated, writing to keychain') + emit(keysActions.saveDeviceCredentials(payload)) + }) socket.on(SocketEvents.USER_PROFILES_UPDATED, async (payload: UserProfilesUpdatedPayload) => { logger.info('User profiles updated, saving in ios native storage') emit(usersMetadataActions.saveUserMetadataNatively(payload)) diff --git a/packages/mobile/src/store/keys/keys.master.saga.ts b/packages/mobile/src/store/keys/keys.master.saga.ts index a35dff4a2a..10a0200f11 100644 --- a/packages/mobile/src/store/keys/keys.master.saga.ts +++ b/packages/mobile/src/store/keys/keys.master.saga.ts @@ -3,6 +3,7 @@ import { all } from 'typed-redux-saga' import { type Socket } from '@quiet/state-manager/src/types' import { keysActions } from './keys.slice' import { saveKeysInKeychainSaga } from './saveKeysInKeychain/saveKeysInKeychain.saga' +import { saveDeviceCredentialsSaga } from './saveDeviceCredentials/saveDeviceCredentials.saga' import { createLogger } from '../../utils/logger' const logger = createLogger('keysMasterSaga') @@ -10,7 +11,10 @@ const logger = createLogger('keysMasterSaga') export function* keysMasterSaga(): Generator { logger.info('keysMasterSaga starting') try { - yield all([takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga)]) + yield all([ + takeEvery(keysActions.saveKeysInKeychain.type, saveKeysInKeychainSaga), + takeEvery(keysActions.saveDeviceCredentials.type, saveDeviceCredentialsSaga), + ]) } finally { logger.info('keysMasterSaga stopping') if (yield cancelled()) { diff --git a/packages/mobile/src/store/keys/keys.slice.ts b/packages/mobile/src/store/keys/keys.slice.ts index 884b147bcf..1a243c57fc 100644 --- a/packages/mobile/src/store/keys/keys.slice.ts +++ b/packages/mobile/src/store/keys/keys.slice.ts @@ -1,6 +1,6 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit' import { StoreKeys } from '../store.keys' -import { KeysUpdatedEvent } from '@quiet/types' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from '@quiet/types' import { createLogger } from '../../utils/logger' const logger = createLogger('keysSlice') @@ -12,6 +12,7 @@ export const keysSlice = createSlice({ name: StoreKeys.Keys, reducers: { saveKeysInKeychain: (state, _action: PayloadAction) => state, + saveDeviceCredentials: (state, _action: PayloadAction) => state, }, }) diff --git a/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts new file mode 100644 index 0000000000..ec50161247 --- /dev/null +++ b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.ts @@ -0,0 +1,22 @@ +import { type PayloadAction } from '@reduxjs/toolkit' +import { call } from 'typed-redux-saga' +import { NativeModules } from 'react-native' + +import { DeviceCredentialsUpdatedEvent } from '@quiet/types' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('saveDeviceCredentialsSaga') + +export function* saveDeviceCredentialsSaga(action: PayloadAction): Generator { + logger.info('Storing device credentials in iOS keychain') + try { + yield* call( + NativeModules.CommunicationModule.saveDeviceCredentials, + action.payload.deviceId, + action.payload.teamId, + action.payload.signingPrivateKey + ) + } catch (e) { + logger.error('Error storing device credentials', e) + } +} diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index 7939aac759..f6ecd314dd 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -8,3 +8,10 @@ export interface StorableKey { export interface KeysUpdatedEvent { keys: StorableKey[] } + +export interface DeviceCredentialsUpdatedEvent { + deviceId: string + teamId: string + /** Base58-encoded 64-byte libsodium Ed25519 signing private key */ + signingPrivateKey: string +} diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index 9fb324a17a..36e6730bf2 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -40,7 +40,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' -import { KeysUpdatedEvent } from './keys' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- // SocketActions: These are the actions the frontend emits to the backend @@ -135,6 +135,7 @@ export enum SocketEvents { USERS_REMOVED = 'usersRemoved', USER_PROFILES_STORED = 'userProfilesStored', KEYS_UPDATED = 'keysUpdated', + DEVICE_CREDENTIALS_UPDATED = 'deviceCredentialsUpdated', USER_PROFILES_UPDATED = 'userProfilesUpdatedFwd', // ====== Files ====== @@ -242,6 +243,7 @@ export interface SocketEventsMap { [SocketEvents.USERS_REMOVED]: EmitEvent [SocketEvents.USER_PROFILES_STORED]: EmitEvent [SocketEvents.KEYS_UPDATED]: EmitEvent + [SocketEvents.DEVICE_CREDENTIALS_UPDATED]: EmitEvent [SocketEvents.USER_PROFILES_UPDATED]: EmitEvent // ====== Files ====== From a12cc5651babd4c33d51cfd733f5e898486cadcd Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 26 Mar 2026 00:33:24 -0400 Subject: [PATCH 33/92] Implement NSE sync timestamp handling and related socket events; improve badge behavior --- packages/backend/src/nest/qss/qss.service.ts | 36 ++++++- .../nest/storage/orbitDb/orbitDb.service.ts | 4 +- packages/mobile/.env.development | 2 +- packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 29 +++++ packages/mobile/ios/Quiet/AppDelegate.m | 13 +++ .../NSEKeychainHelper.swift | 30 +++++- .../NotificationService.swift | 101 +++++++++++++++--- packages/mobile/src/setupTests.tsx | 1 + .../startConnection/startConnection.saga.ts | 18 +++- .../appConnection/connection.selectors.ts | 3 + .../sagas/appConnection/connection.slice.ts | 7 ++ .../startConnection/startConnection.saga.ts | 10 ++ packages/types/src/keys.ts | 5 + packages/types/src/socket.ts | 8 +- 15 files changed, 246 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 7e99cb4f29..856510c124 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -42,7 +42,13 @@ import { DLQDecryptEntry } from '../local-db/local-db.types' import { LogUpdate } from '../storage/orbitDb/orbitdb.types' import { logEntryToLogUpdate } from '../storage/orbitDb/util' import { QSS_RECONNECT_DELAY_MS } from './qss.const' -import { CompoundError, InvitationDataV3, SocketActions, SocketEvents } from '@quiet/types' +import { + CompoundError, + InvitationDataV3, + NseSyncTimestampUpdatedEvent, + SocketActions, + SocketEvents, +} from '@quiet/types' import { LocalDbEvents } from '../local-db/local-db.types' import { SocketService } from '../socket/socket.service' import { Serializer } from '../common/serializer.service' @@ -205,7 +211,10 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.qssClient.on(WebsocketEvents.LOG_ENTRY_SYNC, async (message: LogEntrySyncMessage): Promise => { this.logger.debug('Forwarding fanout log entry sync message to OrbitDB service') - this.orbitDbService.handleFanoutMessage(message) + const ingested = await this.orbitDbService.handleFanoutMessage(message) + if (ingested) { + await this.updateNseLastSyncTimestamp(message.payload.teamId, message.ts) + } }) this.on(QSSEvents.QSS_HANDLE_SIGN_IN, async () => { @@ -895,7 +904,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul try { await this.orbitDbService.ingestEntries(decryptedEntries) - await this.localDbService.setLastSyncTime(teamId, newSyncTime) + await this.updateNseLastSyncTimestamp(teamId, newSyncTime) } catch (e) { this.logger.error('Failed to ingest pulled log entries from QSS into OrbitDB', e) } @@ -916,6 +925,27 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul return finalPullResponse } + private async updateNseLastSyncTimestamp(teamId: string, timestamp: number): Promise { + if (!Number.isFinite(timestamp) || timestamp <= 0) { + this.logger.warn(`Refusing to persist invalid NSE sync timestamp for team ${teamId}: ${timestamp}`) + return + } + + const existingTimestamp = await this.localDbService.getLastSyncTime(teamId) + const nextTimestamp = existingTimestamp == null ? timestamp : Math.max(existingTimestamp, timestamp) + + if (existingTimestamp === nextTimestamp) { + return + } + + await this.localDbService.setLastSyncTime(teamId, nextTimestamp) + const payload: NseSyncTimestampUpdatedEvent = { + teamId, + lastSyncTimestamp: nextTimestamp, + } + this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, payload) + } + /** * Process the decryption dead letter queue when sigchain updates (new keys arrive) */ diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index e26aaff46b..33240cc466 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -239,7 +239,7 @@ export class OrbitDbService { await Promise.all(joinAll) } - public async handleFanoutMessage(message: LogEntrySyncMessage): Promise { + public async handleFanoutMessage(message: LogEntrySyncMessage): Promise { this.logger.debug('Ingesting fanout message, ', message.payload.hash) try { const logEntry: LogEntry = this.sigChainService.crypto.decryptAndVerify( @@ -247,8 +247,10 @@ export class OrbitDbService { message.payload.encEntry.signature ).contents await this.ingestEntries([logEntry]) + return true } catch (err) { this.logger.error(`Failed to handle fanout log entry sync message`, err) + return false } } diff --git a/packages/mobile/.env.development b/packages/mobile/.env.development index 40ea712d18..98cc3b88dd 100644 --- a/packages/mobile/.env.development +++ b/packages/mobile/.env.development @@ -3,5 +3,5 @@ NODE_ENV=development SHOULD_RUN_BACKEND_WORKER=true COLORIZE=false QSS_ALLOWED=true -QSS_ENDPOINT=ws://127.0.0.1:3003 +QSS_ENDPOINT=ws://192.168.1.175:3003 QPS_ALLOWED=true diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 50b6c804a4..a044434144 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -8,4 +8,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys) RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata) RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey) +RCT_EXTERN_METHOD(saveNseLastSyncTimestamp:(nonnull NSNumber *)timestamp) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 5c37920ec0..fc0795fe67 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -1,5 +1,6 @@ import UserNotifications import OSLog +import UIKit @objc(CommunicationModule) class CommunicationModule: RCTEventEmitter { @@ -11,6 +12,10 @@ class CommunicationModule: RCTEventEmitter { static let APP_RESUME_IDENTIFIER = "appresume" static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult" static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" + static let NSE_LAST_SYNC_KEY = "quiet.nse.lastSyncTimestamp" + static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount" + static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground" + static let APP_GROUP_IDENTIFIER = "group.com.quietmobile" static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_" private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule") @@ -32,11 +37,18 @@ class CommunicationModule: RCTEventEmitter { @objc func appPause() { + let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard + defaults.set(false, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY) self.sendEvent(withName: CommunicationModule.APP_PAUSE_IDENTIFIER, body: nil) } @objc func appResume() { + let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard + defaults.set(true, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY) + defaults.set(0, forKey: CommunicationModule.NSE_BADGE_COUNT_KEY) + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + UIApplication.shared.applicationIconBadgeNumber = 0 self.sendEvent(withName: CommunicationModule.APP_RESUME_IDENTIFIER, body: nil) } @@ -147,6 +159,23 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func saveNseLastSyncTimestamp(_ timestamp: NSNumber) { + let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard + let newTimestamp = timestamp.doubleValue + let existingTimestamp = defaults.double(forKey: CommunicationModule.NSE_LAST_SYNC_KEY) + + if existingTimestamp >= newTimestamp { + CommunicationModule.logger.debug( + "saveNseLastSyncTimestamp: ignoring stale timestamp \(newTimestamp, privacy: .public), existing=\(existingTimestamp, privacy: .public)" + ) + return + } + + defaults.set(newTimestamp, forKey: CommunicationModule.NSE_LAST_SYNC_KEY) + CommunicationModule.logger.info("saveNseLastSyncTimestamp: stored \(newTimestamp, privacy: .public)") + } + @objc func checkNotificationPermission() { UNUserNotificationCenter.current().getNotificationSettings { settings in diff --git a/packages/mobile/ios/Quiet/AppDelegate.m b/packages/mobile/ios/Quiet/AppDelegate.m index cca2289b11..0893562d27 100644 --- a/packages/mobile/ios/Quiet/AppDelegate.m +++ b/packages/mobile/ios/Quiet/AppDelegate.m @@ -13,6 +13,16 @@ @implementation AppDelegate static NSString *const platform = @"mobile"; +static NSString *const QuietAppGroupIdentifier = @"group.com.quietmobile"; +static NSString *const QuietAppIsForegroundKey = @"quiet.app.isForeground"; + +static void QuietSetAppForegroundFlag(BOOL isForeground) { + NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:QuietAppGroupIdentifier]; + if (defaults == nil) { + defaults = [NSUserDefaults standardUserDefaults]; + } + [defaults setBool:isForeground forKey:QuietAppIsForegroundKey]; +} - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url @@ -31,6 +41,7 @@ - (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull N - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + QuietSetAppForegroundFlag(YES); self.moduleName = @"QuietMobile"; // You can add your custom initial props in the dictionary below. // They will be passed down to the ViewController used by React Native. @@ -185,6 +196,7 @@ - (void) stopTor { - (void)applicationDidEnterBackground:(UIApplication *)application { + QuietSetAppForegroundFlag(NO); [self stopTor]; NSString * message = [NSString stringWithFormat:@"app:close"]; @@ -202,6 +214,7 @@ - (void)applicationDidEnterBackground:(UIApplication *)application - (void)applicationWillEnterForeground:(UIApplication *)application { + QuietSetAppForegroundFlag(YES); // Display splash screen until services become available again dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSTimeInterval delayInSeconds = 0; diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index d3e20f42c5..c70ac96447 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -1,5 +1,6 @@ import Foundation import Security +import OSLog struct NSEKeychainHelper { @@ -9,6 +10,8 @@ struct NSEKeychainHelper { private static let deviceIdKey = "quiet.device.id" private static let teamIdKey = "quiet.team.id" private static let lastSyncKey = "quiet.nse.lastSyncTimestamp" + private static let lastSyncCidsKey = "quiet.nse.lastSyncCids" + private static let appIsForegroundKey = "quiet.app.isForeground" private static let lfaKeyService = "com.quietmobile" // MARK: - Device private key @@ -61,7 +64,32 @@ struct NSEKeychainHelper { static func saveLastSyncTimestamp(_ ts: Int64) { let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard - defaults.set(Double(ts), forKey: lastSyncKey) + let current = Int64(defaults.double(forKey: lastSyncKey)) + os_log("Saving last sync timestamp: current=%{public}lld, new=%{public}lld", current, ts) + defaults.set(Double(max(current, ts)), forKey: lastSyncKey) + } + + static func getLastSyncCids() -> [String] { + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + return defaults.stringArray(forKey: lastSyncCidsKey) ?? [] + } + + static func saveLastSyncState(timestamp: Int64, cids: [String]) { + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + let currentTimestamp = Int64(defaults.double(forKey: lastSyncKey)) + if timestamp < currentTimestamp { + os_log("Ignoring stale sync state save: current=%{public}lld, new=%{public}lld", currentTimestamp, timestamp) + return + } + + defaults.set(Double(timestamp), forKey: lastSyncKey) + defaults.set(Array(Set(cids)).sorted(), forKey: lastSyncCidsKey) + os_log("Saved sync state: timestamp=%{public}lld cids=%{public}@", timestamp, cids.joined(separator: ",")) + } + + static func isMainAppForeground() -> Bool { + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + return defaults.bool(forKey: appIsForegroundKey) } // MARK: - Private helpers diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 80b2e05d4f..4305432752 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -11,6 +11,13 @@ import os.log private let nseLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NotificationService") class NotificationService: UNNotificationServiceExtension { + private static let appGroupIdentifier = "group.com.quietmobile" + private static let badgeCountKey = "quiet.nse.badgeCount" + + private struct TimedEntry { + let entry: LogEntry + let timestamp: Int64? + } var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? @@ -54,7 +61,12 @@ class NotificationService: UNNotificationServiceExtension { private func fetchAndUpdate(userInfo: [AnyHashable: Any]) async { defer { deliver() } - os_log("fetchAndUpdate: start", log: nseLog, type: .debug) + os_log("fetchAndUpdate: start", log: nseLog, type: .info) + + if NSEKeychainHelper.isMainAppForeground() { + os_log("fetchAndUpdate: app is foregrounded, skipping NSE fetch/decrypt work", log: nseLog, type: .info) + return + } guard let teamId = userInfo["teamId"] as? String else { os_log("fetchAndUpdate: missing 'teamId' in userInfo; payload keys=%{public}@", @@ -92,6 +104,7 @@ class NotificationService: UNNotificationServiceExtension { } let since = NSEKeychainHelper.getLastSyncTimestamp() + let lastSyncCids = Set(NSEKeychainHelper.getLastSyncCids()) os_log("fetchAndUpdate: fetching entries since=%{public}lld", log: nseLog, type: .info, since) let entries = try await auth.fetchNewEntries(teamId: teamId, since: since) @@ -105,17 +118,74 @@ class NotificationService: UNNotificationServiceExtension { if entries.isEmpty { os_log("fetchAndUpdate: no new entries, delivering as-is", log: nseLog, type: .info) } else { - let newTs = entries.lazy - .compactMap { Self.iso8601.date(from: $0.receivedAt) } - .map { Int64($0.timeIntervalSince1970 * 1000) } - .max() - if let newTs { - os_log("fetchAndUpdate: saving lastSyncTimestamp=%{public}lld", log: nseLog, type: .info, newTs) - NSEKeychainHelper.saveLastSyncTimestamp(newTs) + let timedEntries = entries.map { entry in + let parsedDate = Self.iso8601.date(from: entry.receivedAt) + let timestamp = parsedDate.map { Int64($0.timeIntervalSince1970 * 1000) } + return TimedEntry(entry: entry, timestamp: timestamp) + } + + let unseenEntries = timedEntries.filter { timedEntry in + guard let timestamp = timedEntry.timestamp else { + return true + } + if timestamp > since { + return true + } + if timestamp < since { + return false + } + return !lastSyncCids.contains(timedEntry.entry.cid) + } + + if unseenEntries.isEmpty { + os_log("fetchAndUpdate: no unseen entries after cursor filtering", log: nseLog, type: .info) + return + } + + let sortedEntries = unseenEntries.sorted { lhs, rhs in + let leftTs = lhs.timestamp ?? Int64.min + let rightTs = rhs.timestamp ?? Int64.min + if leftTs != rightTs { + return leftTs < rightTs + } + return lhs.entry.cid < rhs.entry.cid + } + + let isBootstrapSync = since == 0 + let notificationEntries: [TimedEntry] + if isBootstrapSync, let newestTimestamp = sortedEntries.compactMap(\.timestamp).max() { + notificationEntries = sortedEntries.filter { $0.timestamp == newestTimestamp } + os_log( + "fetchAndUpdate: bootstrap sync detected, collapsing %{public}d fetched entries to %{public}d newest entries", + log: nseLog, + type: .info, + sortedEntries.count, + notificationEntries.count + ) + } else { + notificationEntries = sortedEntries + } + + let maxTimestamp = sortedEntries.compactMap(\.timestamp).max() + if let maxTimestamp { + let cidsAtMaxTimestamp = sortedEntries + .filter { $0.timestamp == maxTimestamp } + .map(\.entry.cid) + os_log( + "fetchAndUpdate: saving sync state timestamp=%{public}lld with %{public}d cid(s)", + log: nseLog, + type: .info, + maxTimestamp, + cidsAtMaxTimestamp.count + ) + NSEKeychainHelper.saveLastSyncState(timestamp: maxTimestamp, cids: cidsAtMaxTimestamp) } else { // All receivedAt failed to parse — advance by 1ms to avoid reprocessing os_log("All receivedAt timestamps failed to parse; advancing sync pointer", log: nseLog, type: .fault) - NSEKeychainHelper.saveLastSyncTimestamp(NSEKeychainHelper.getLastSyncTimestamp() + 1) + NSEKeychainHelper.saveLastSyncState( + timestamp: NSEKeychainHelper.getLastSyncTimestamp() + 1, + cids: notificationEntries.map(\.entry.cid) + ) } guard let content = bestAttemptContent else { @@ -123,15 +193,15 @@ class NotificationService: UNNotificationServiceExtension { return } - let decryptedMessages = entries.compactMap { entry -> NSEDecryptedNotificationMessage? in + let decryptedMessages = notificationEntries.compactMap { timedEntry -> NSEDecryptedNotificationMessage? in do { - return try self.crypto.decryptNotificationMessage(from: entry, teamId: teamId) + return try self.crypto.decryptNotificationMessage(from: timedEntry.entry, teamId: teamId) } catch { os_log( "fetchAndUpdate: failed to decrypt entry %{public}@: %{public}@", log: nseLog, type: .error, - entry.cid, + timedEntry.entry.cid, String(describing: error) ) return nil @@ -159,9 +229,12 @@ class NotificationService: UNNotificationServiceExtension { os_log("fetchAndUpdate: no decryptable channel messages found", log: nseLog, type: .info) } - let badgeIncrement = decryptedMessages.isEmpty ? entries.count : decryptedMessages.count - let newBadge = (content.badge?.intValue ?? 0) + badgeIncrement + let badgeIncrement = decryptedMessages.isEmpty ? notificationEntries.count : decryptedMessages.count + let defaults = UserDefaults(suiteName: Self.appGroupIdentifier) ?? UserDefaults.standard + let storedBadgeCount = max(0, defaults.integer(forKey: Self.badgeCountKey)) + let newBadge = storedBadgeCount + badgeIncrement os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) + defaults.set(newBadge, forKey: Self.badgeCountKey) content.badge = newBadge as NSNumber } } catch { diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index c668908f41..84fd2e5065 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -47,6 +47,7 @@ jest.mock('react-native', () => { requestNotificationPermission: jest.fn(), checkNotificationPermission: jest.fn(), handleIncomingEvents: jest.fn(), + saveNseLastSyncTimestamp: jest.fn(), } rn.NativeModules.FirebaseMessagingModule = { getToken: jest.fn(), diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 8f89626a39..49ef406f6e 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -1,4 +1,5 @@ import { io } from 'socket.io-client' +import { NativeModules } from 'react-native' import { select, put, @@ -16,7 +17,14 @@ import { PayloadAction } from '@reduxjs/toolkit' import { socket as stateManager, Socket } from '@quiet/state-manager' import { initActions, WebsocketConnectionPayload } from '../init.slice' import { eventChannel } from 'redux-saga' -import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload } from '@quiet/types' +import { + DeviceCredentialsUpdatedEvent, + KeysUpdatedEvent, + NseSyncTimestampUpdatedEvent, + SocketActions, + SocketEvents, + UserProfilesUpdatedPayload, +} from '@quiet/types' import { createLogger } from '../../../utils/logger' import { initSelectors } from '../init.selectors' import { keysActions } from '../../keys/keys.slice' @@ -105,6 +113,14 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.info('User profiles updated, saving in ios native storage') emit(usersMetadataActions.saveUserMetadataNatively(payload)) }) + socket.on(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, async (payload: NseSyncTimestampUpdatedEvent) => { + logger.info(`NSE sync timestamp updated for team ${payload.teamId}, saving in shared iOS storage`) + try { + await NativeModules.CommunicationModule?.saveNseLastSyncTimestamp?.(payload.lastSyncTimestamp) + } catch (error) { + logger.error('Failed to store NSE sync timestamp in iOS native storage', error) + } + }) return () => {} }) } diff --git a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts index 4b8830c546..9536cdffc9 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.selectors.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.selectors.ts @@ -21,6 +21,8 @@ export const torBootstrapProcess = createSelector(connectionSlice, reducerState export const isTorInitialized = createSelector(connectionSlice, reducerState => reducerState.isTorInitialized) +export const isQssConnected = createSelector(connectionSlice, reducerState => reducerState.isQssConnected) + export const connectionProcess = createSelector(connectionSlice, reducerState => reducerState.connectionProcess) export const socketIOSecret = createSelector(connectionSlice, reducerState => reducerState.socketIOSecret) @@ -141,6 +143,7 @@ export const connectionSelectors = { torBootstrapProcess, connectionProcess, isTorInitialized, + isQssConnected, socketIOSecret, isJoiningCompleted, peerStats, diff --git a/packages/state-manager/src/sagas/appConnection/connection.slice.ts b/packages/state-manager/src/sagas/appConnection/connection.slice.ts index ace502d093..2145398445 100644 --- a/packages/state-manager/src/sagas/appConnection/connection.slice.ts +++ b/packages/state-manager/src/sagas/appConnection/connection.slice.ts @@ -17,6 +17,7 @@ export class ConnectionState { public uptime = 0 public peersStats: EntityState = peersStatsAdapter.getInitialState() public isTorInitialized = false + public isQssConnected = false public socketIOSecret: string | null = null public torBootstrapProcess = 'Bootstrapped 0% (starting)' public connectionProcess: { number: number; text: ConnectionProcessInfo } = { @@ -59,6 +60,12 @@ export const connectionSlice = createSlice({ setTorInitialized: state => { state.isTorInitialized = true }, + setQssConnected: state => { + state.isQssConnected = true + }, + setQssDisconnected: state => { + state.isQssConnected = false + }, setLongLivedInvite: (state, action: PayloadAction) => { state.longLivedInvite = action.payload }, diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index e988254ddf..ef3ebff65e 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -88,6 +88,8 @@ export function subscribe(socket: Socket) { | ReturnType | ReturnType | ReturnType + | ReturnType + | ReturnType | ReturnType | ReturnType | ReturnType @@ -107,6 +109,14 @@ export function subscribe(socket: Socket) { logger.info(`${SocketEvents.TOR_INITIALIZED}`) emit(connectionActions.setTorInitialized()) }) + socket.on(SocketEvents.QSS_CONNECTED, () => { + logger.info(`${SocketEvents.QSS_CONNECTED}`) + emit(connectionActions.setQssConnected()) + }) + socket.on(SocketEvents.QSS_DISCONNECTED, () => { + logger.info(`${SocketEvents.QSS_DISCONNECTED}`) + emit(connectionActions.setQssDisconnected()) + }) socket.on(SocketEvents.CONNECTION_PROCESS_INFO, (payload: string) => { logger.info(`${SocketEvents.CONNECTION_PROCESS_INFO}`, payload) emit(connectionActions.onConnectionProcessInfo(payload)) diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index f6ecd314dd..15ad7bce01 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -15,3 +15,8 @@ export interface DeviceCredentialsUpdatedEvent { /** Base58-encoded 64-byte libsodium Ed25519 signing private key */ signingPrivateKey: string } + +export interface NseSyncTimestampUpdatedEvent { + teamId: string + lastSyncTimestamp: number +} diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index 36e6730bf2..730c029e77 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -40,7 +40,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' -import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent } from './keys' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- // SocketActions: These are the actions the frontend emits to the backend @@ -150,6 +150,9 @@ export enum SocketEvents { PEER_CONNECTED = 'peerConnected', PEER_DISCONNECTED = 'peerDisconnected', TOR_INITIALIZED = 'torInitialized', + QSS_CONNECTED = 'qssConnected', + QSS_DISCONNECTED = 'qssDisconnected', + NSE_SYNC_TIMESTAMP_UPDATED = 'nseSyncTimestampUpdated', MIGRATION_DATA_REQUIRED = 'migrationDataRequired', PUSH_NOTIFICATION = 'pushNotification', CONNECTION_PROCESS_INFO = 'connectionProcess', @@ -258,6 +261,9 @@ export interface SocketEventsMap { [SocketEvents.PEER_CONNECTED]: EmitEvent [SocketEvents.PEER_DISCONNECTED]: EmitEvent [SocketEvents.TOR_INITIALIZED]: EmitEvent + [SocketEvents.QSS_CONNECTED]: EmitEvent + [SocketEvents.QSS_DISCONNECTED]: EmitEvent + [SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED]: EmitEvent [SocketEvents.MIGRATION_DATA_REQUIRED]: EmitEvent [SocketEvents.PUSH_NOTIFICATION]: EmitEvent [SocketEvents.CONNECTION_PROCESS_INFO]: EmitEvent From 134181f4bcc04b9a5b9c91cb265dcb8e5b2256cb Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 26 Mar 2026 12:49:26 -0400 Subject: [PATCH 34/92] pass qss_endpoint to nse from backend --- .../connections-manager.service.spec.ts | 36 +++++++++++- .../connections-manager.service.ts | 55 +++++++++++++++++++ .../backend/src/nest/qps/qps.service.spec.ts | 38 ++++++++++++- packages/backend/src/nest/qps/qps.service.ts | 12 ++-- packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 18 ++++++ .../NSEKeychainHelper.swift | 13 +++++ .../NotificationService.swift | 12 ++-- packages/mobile/src/setupTests.tsx | 1 + .../startConnection/startConnection.saga.ts | 9 +++ packages/types/src/keys.ts | 5 ++ packages/types/src/socket.ts | 4 +- 12 files changed, 185 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index 971586afec..c1d936f9e1 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' import { getReduxStoreFactory, prepareStore, type Store } from '@quiet/state-manager' -import { CommunityOwnership, type Community, type Identity } from '@quiet/types' +import { CommunityOwnership, SocketEvents, type Community, type Identity } from '@quiet/types' import { type FactoryGirl } from 'factory-girl' import { TestModule } from '../common/test.module' import { removeFilesFromDir } from '../common/utils' @@ -17,6 +17,7 @@ import { createLibp2pAddress } from '@quiet/common' import { createLogger } from '../common/logger' import { SigChainService } from '../auth/sigchain.service' import { StorageModule } from '../storage/storage.module' +import { QSSService } from '../qss/qss.service' const logger = createLogger('connections-manager.service.spec') @@ -31,6 +32,7 @@ describe('ConnectionsManagerService', () => { let userIdentity: Identity let communityRootCa: string let sigChainService: SigChainService + let qssService: QSSService beforeEach(async () => { jest.clearAllMocks() @@ -54,6 +56,7 @@ describe('ConnectionsManagerService', () => { connectionsManagerService = await module.resolve(ConnectionsManagerService) localDbService = await module.resolve(LocalDbService) sigChainService = await module.resolve(SigChainService) + qssService = await module.resolve(QSSService) localDbService.open() // initialize sigchain on local db @@ -130,4 +133,35 @@ describe('ConnectionsManagerService', () => { expect(launchSpy).toBeCalledTimes(1) }) + + it('emits the authoritative QSS URL for the NSE on iOS', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + value: 'ios', + }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEndpoint: 'wss://community.example/ws', + }) + await localDbService.setCurrentCommunityId(community.id) + + qssService._qssEndpoint = 'wss://authoritative.example' + + const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') + + await (connectionsManagerService as any).emitNseQssUrl() + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'https://authoritative.example', + }) + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }) + } + }) }) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 32ba736d90..11631f9cae 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -50,6 +50,7 @@ import { InvitationData, SetUserProfileResponse, UserProfilesUpdatedPayload, + NseQssUrlUpdatedEvent, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -590,6 +591,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI // Unblock websocket endpoints this.socketService.resolveReadyness() + void this.emitNseQssUrl() this.serverIoProvider.io.emit(SocketEvents.COMMUNITY_LAUNCHED, { id: community.id, } as LaunchCommunityPayload) @@ -675,10 +677,63 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI }) } + private getNseQssUrl(wsUrl: string | undefined): string | undefined { + if (wsUrl == null || wsUrl === '') { + return undefined + } + + if (wsUrl.startsWith('wss://')) { + return `https://${wsUrl.slice('wss://'.length)}` + } + + if (wsUrl.startsWith('ws://')) { + return `http://${wsUrl.slice('ws://'.length)}` + } + + this.logger.warn('Skipping NSE QSS URL update because endpoint is not ws/wss', wsUrl) + return undefined + } + + private async emitNseQssUrl(socket?: { emit: (event: string, payload: NseQssUrlUpdatedEvent) => void }): Promise { + if ((process.platform as string) !== 'ios') { + return + } + + const community = await this.localDbService.getCurrentCommunity() + if (community?.teamId == null) { + return + } + + const wsUrl = this.qssService.qssEndpoint ?? community.qssEndpoint ?? this.qssEndpoint + const qssUrl = this.getNseQssUrl(wsUrl) + if (qssUrl == null) { + return + } + + const payload: NseQssUrlUpdatedEvent = { + teamId: community.teamId, + qssUrl, + } + + if (socket != null) { + socket.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) + return + } + + this.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) + } + /** * Attaches listeners for events received from the state manager */ private attachSocketServiceListeners() { + this.serverIoProvider.io.on(SocketActions.CONNECTION, socket => { + void this.emitNseQssUrl(socket) + socket.on(SocketActions.START, () => { + void this.emitNseQssUrl(socket) + }) + }) + // Community this.socketService.on(SocketActions.CONNECTION, () => { this.logger.info(`socketService - ${SocketActions.CONNECTION}`) diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index 9f73d0d4eb..89730437d1 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -302,7 +302,29 @@ describe('QPSService', () => { WebsocketEvents.SEND_BATCH_PUSH, expect.objectContaining({ status: CommunityOperationStatus.SENDING, - payload: { ucans: UCANS }, + payload: expect.objectContaining({ + ucans: UCANS, + data: { teamId: TEAM_ID }, + }), + }), + true + ) + }) + + it('strips qssUrl from push data before sending to QPS', async () => { + await qpsService.sendBatchPush(TEAM_ID, 'title', 'body', { + cid: 'cid-1', + qssUrl: 'https://untrusted.example', + }) + + expect(qssClient.sendMessage).toHaveBeenCalledWith( + WebsocketEvents.SEND_BATCH_PUSH, + expect.objectContaining({ + payload: expect.objectContaining({ + title: 'title', + body: 'body', + data: { teamId: TEAM_ID, cid: 'cid-1' }, + }), }), true ) @@ -367,13 +389,23 @@ describe('QPSService', () => { expect(qssClient.sendMessage).toHaveBeenNthCalledWith( 1, WebsocketEvents.SEND_BATCH_PUSH, - expect.objectContaining({ payload: { ucans: manyUcans.slice(0, 500) } }), + expect.objectContaining({ + payload: expect.objectContaining({ + ucans: manyUcans.slice(0, 500), + data: { teamId: TEAM_ID }, + }), + }), true ) expect(qssClient.sendMessage).toHaveBeenNthCalledWith( 2, WebsocketEvents.SEND_BATCH_PUSH, - expect.objectContaining({ payload: { ucans: manyUcans.slice(500) } }), + expect.objectContaining({ + payload: expect.objectContaining({ + ucans: manyUcans.slice(500), + data: { teamId: TEAM_ID }, + }), + }), true ) }) diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts index fc3461d106..859bc1d167 100644 --- a/packages/backend/src/nest/qps/qps.service.ts +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -151,15 +151,10 @@ export class QPSService implements OnModuleInit { batches.push(ucans.slice(i, i + PUSH_BATCH_SIZE)) } - // Convert ws:// → http:// (or wss:// → https://) so the NSE can use this - // URL for HTTP REST calls to /nse-auth/. URLSession does not support ws:// - // scheme for regular data tasks. - const wsUrl = this.qssService.qssEndpoint - const qssUrl = wsUrl?.replace(/^wss?:\/\//, match => (match === 'wss://' ? 'https://' : 'http://')) + const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {} const mergedData: Record = { teamId, - ...(qssUrl != null ? { qssUrl } : {}), - ...data, + ...safeData, } this.logger.info( @@ -204,12 +199,13 @@ export class QPSService implements OnModuleInit { } try { + const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {} return await this.qssClient.sendMessage( WebsocketEvents.SEND_PUSH, { ts: DateTime.utc().toMillis(), status: CommunityOperationStatus.SENDING, - payload: { ucan, title, body, data }, + payload: { ucan, title, body, data: safeData }, }, true ) diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index a044434144..7b0f5abbba 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -8,5 +8,6 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(saveKeysInKeychain:(NSArray *)newKeys) RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata) RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey) +RCT_EXTERN_METHOD(saveNseQssUrl:(NSString *)teamId qssUrl:(NSString *)qssUrl) RCT_EXTERN_METHOD(saveNseLastSyncTimestamp:(nonnull NSNumber *)timestamp) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index fc0795fe67..49ed5e99e8 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -13,6 +13,7 @@ class CommunicationModule: RCTEventEmitter { static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult" static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" static let NSE_LAST_SYNC_KEY = "quiet.nse.lastSyncTimestamp" + static let NSE_QSS_URLS_KEY = "quiet.nse.qssUrls" static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount" static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground" static let APP_GROUP_IDENTIFIER = "group.com.quietmobile" @@ -159,6 +160,23 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func saveNseQssUrl(_ teamId: NSString, qssUrl: NSString) { + let teamIdStr = teamId as String + let qssUrlStr = qssUrl as String + let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard + var existing = defaults.dictionary(forKey: CommunicationModule.NSE_QSS_URLS_KEY) as? [String: String] ?? [:] + + if existing[teamIdStr] == qssUrlStr { + CommunicationModule.logger.debug("saveNseQssUrl: unchanged for team \(teamIdStr, privacy: .public)") + return + } + + existing[teamIdStr] = qssUrlStr + defaults.set(existing, forKey: CommunicationModule.NSE_QSS_URLS_KEY) + CommunicationModule.logger.info("saveNseQssUrl: stored for team \(teamIdStr, privacy: .public)") + } + @objc func saveNseLastSyncTimestamp(_ timestamp: NSNumber) { let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index c70ac96447..b91ed2634e 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -11,6 +11,7 @@ struct NSEKeychainHelper { private static let teamIdKey = "quiet.team.id" private static let lastSyncKey = "quiet.nse.lastSyncTimestamp" private static let lastSyncCidsKey = "quiet.nse.lastSyncCids" + private static let qssUrlsKey = "quiet.nse.qssUrls" private static let appIsForegroundKey = "quiet.app.isForeground" private static let lfaKeyService = "com.quietmobile" @@ -87,6 +88,18 @@ struct NSEKeychainHelper { os_log("Saved sync state: timestamp=%{public}lld cids=%{public}@", timestamp, cids.joined(separator: ",")) } + static func getQssUrl(teamId: String) -> URL? { + let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + guard + let qssUrls = defaults.dictionary(forKey: qssUrlsKey) as? [String: String], + let qssUrlString = qssUrls[teamId] + else { + return nil + } + + return URL(string: qssUrlString) + } + static func isMainAppForeground() -> Bool { let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard return defaults.bool(forKey: appIsForegroundKey) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 4305432752..c397844ae8 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -75,16 +75,16 @@ class NotificationService: UNNotificationServiceExtension { return } - guard let qssUrlString = userInfo["qssUrl"] as? String else { - os_log("fetchAndUpdate: missing 'qssUrl' in userInfo (teamId=%{public}@)", + guard let qssUrl = NSEKeychainHelper.getQssUrl(teamId: teamId) else { + os_log("fetchAndUpdate: missing stored QSS URL for teamId=%{public}@", log: nseLog, type: .error, teamId) return } - guard let qssUrl = URL(string: qssUrlString) else { - os_log("fetchAndUpdate: 'qssUrl' is not a valid URL: %{public}@", - log: nseLog, type: .error, qssUrlString) - return + let qssUrlString = qssUrl.absoluteString + if let payloadQssUrl = userInfo["qssUrl"] as? String, payloadQssUrl != qssUrlString { + os_log("fetchAndUpdate: ignoring push payload qssUrl for teamId=%{public}@; using stored value", + log: nseLog, type: .info, teamId) } os_log("fetchAndUpdate: teamId=%{public}@ qssUrl=%{public}@", diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index 84fd2e5065..e989a14cb9 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -47,6 +47,7 @@ jest.mock('react-native', () => { requestNotificationPermission: jest.fn(), checkNotificationPermission: jest.fn(), handleIncomingEvents: jest.fn(), + saveNseQssUrl: jest.fn(), saveNseLastSyncTimestamp: jest.fn(), } rn.NativeModules.FirebaseMessagingModule = { diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 49ef406f6e..e271859e0e 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -20,6 +20,7 @@ import { eventChannel } from 'redux-saga' import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, + NseQssUrlUpdatedEvent, NseSyncTimestampUpdatedEvent, SocketActions, SocketEvents, @@ -113,6 +114,14 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.info('User profiles updated, saving in ios native storage') emit(usersMetadataActions.saveUserMetadataNatively(payload)) }) + socket.on(SocketEvents.NSE_QSS_URL_UPDATED, async (payload: NseQssUrlUpdatedEvent) => { + logger.info(`NSE QSS URL updated for team ${payload.teamId}, saving in shared iOS storage`) + try { + await NativeModules.CommunicationModule?.saveNseQssUrl?.(payload.teamId, payload.qssUrl) + } catch (error) { + logger.error('Failed to store NSE QSS URL in iOS native storage', error) + } + }) socket.on(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, async (payload: NseSyncTimestampUpdatedEvent) => { logger.info(`NSE sync timestamp updated for team ${payload.teamId}, saving in shared iOS storage`) try { diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index 15ad7bce01..2e558f88d9 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -16,6 +16,11 @@ export interface DeviceCredentialsUpdatedEvent { signingPrivateKey: string } +export interface NseQssUrlUpdatedEvent { + teamId: string + qssUrl: string +} + export interface NseSyncTimestampUpdatedEvent { teamId: string lastSyncTimestamp: number diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index 730c029e77..1ed66a75cd 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -40,7 +40,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' -import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- // SocketActions: These are the actions the frontend emits to the backend @@ -152,6 +152,7 @@ export enum SocketEvents { TOR_INITIALIZED = 'torInitialized', QSS_CONNECTED = 'qssConnected', QSS_DISCONNECTED = 'qssDisconnected', + NSE_QSS_URL_UPDATED = 'nseQssUrlUpdated', NSE_SYNC_TIMESTAMP_UPDATED = 'nseSyncTimestampUpdated', MIGRATION_DATA_REQUIRED = 'migrationDataRequired', PUSH_NOTIFICATION = 'pushNotification', @@ -263,6 +264,7 @@ export interface SocketEventsMap { [SocketEvents.TOR_INITIALIZED]: EmitEvent [SocketEvents.QSS_CONNECTED]: EmitEvent [SocketEvents.QSS_DISCONNECTED]: EmitEvent + [SocketEvents.NSE_QSS_URL_UPDATED]: EmitEvent [SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED]: EmitEvent [SocketEvents.MIGRATION_DATA_REQUIRED]: EmitEvent [SocketEvents.PUSH_NOTIFICATION]: EmitEvent From 1961e7ebe318962c4a266ef64c568c5ddfe90834 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 30 Mar 2026 13:22:42 -0400 Subject: [PATCH 35/92] avoid dropping key after writing, ensure qss url is shared at join --- .../connections-manager.service.ts | 18 ++++++++-------- packages/mobile/ios/FindFreePort.swift | 4 ++-- packages/mobile/ios/KeychainHandler.swift | 6 ------ .../ios/Quiet.xcodeproj/project.pbxproj | 21 +++++-------------- .../NotificationService.swift | 12 +++++++++-- 5 files changed, 26 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 11631f9cae..3bd195c887 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -394,7 +394,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI peerList: [localAddress], psk: Libp2pService.generateLibp2pPSK().psk, ownership: CommunityOwnership.Owner, - teamId: sigchain.team!.id, + teamId: sigchain.team?.id, qssEnabled: this.qssAllowed && payload.useServer, qssEndpoint: this.qssEndpoint, tosAccepted: payload.tosAccepted, @@ -509,6 +509,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI peerList: [...new Set([localAddress, ...Object.keys(bootstrapPeerStats)])], // TODO: we should deprecate this field and use db inviteData, psk: inviteData.psk, + teamId: inviteData.version === InvitationDataVersion.v3 ? inviteData.authData.teamId : undefined, ownership: CommunityOwnership.User, qssEnabled: inviteData?.version === InvitationDataVersion.v3 ? inviteData.qssEnabled : undefined, qssEndpoint: inviteData?.version === InvitationDataVersion.v3 ? inviteData.qssEndpoint : undefined, @@ -679,6 +680,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI private getNseQssUrl(wsUrl: string | undefined): string | undefined { if (wsUrl == null || wsUrl === '') { + this.logger.warn('Skipping NSE QSS URL update because wsUrl is empty') return undefined } @@ -694,19 +696,22 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI return undefined } - private async emitNseQssUrl(socket?: { emit: (event: string, payload: NseQssUrlUpdatedEvent) => void }): Promise { + private async emitNseQssUrl(): Promise { if ((process.platform as string) !== 'ios') { return } const community = await this.localDbService.getCurrentCommunity() if (community?.teamId == null) { + this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found') + this.logger.warn('Community', community) return } const wsUrl = this.qssService.qssEndpoint ?? community.qssEndpoint ?? this.qssEndpoint const qssUrl = this.getNseQssUrl(wsUrl) if (qssUrl == null) { + this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') return } @@ -715,11 +720,6 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI qssUrl, } - if (socket != null) { - socket.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) - return - } - this.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) } @@ -728,9 +728,9 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI */ private attachSocketServiceListeners() { this.serverIoProvider.io.on(SocketActions.CONNECTION, socket => { - void this.emitNseQssUrl(socket) + void this.emitNseQssUrl() socket.on(SocketActions.START, () => { - void this.emitNseQssUrl(socket) + void this.emitNseQssUrl() }) }) diff --git a/packages/mobile/ios/FindFreePort.swift b/packages/mobile/ios/FindFreePort.swift index 6730548f51..17744c67ee 100644 --- a/packages/mobile/ios/FindFreePort.swift +++ b/packages/mobile/ios/FindFreePort.swift @@ -5,7 +5,7 @@ class FindFreePort: NSObject { func getFirstStartingFrom(port: in_port_t) -> UInt16 { var returned = port; for i in port..<65000 { - let (result, _) = checkPort(port: port) + let (result, _) = checkPort(port: i) if result == true { returned = i; break; @@ -53,5 +53,5 @@ class FindFreePort: NSObject { func descriptionOfLastError() -> String { return String.init(cString: (UnsafePointer(strerror(errno)))) } - + } diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index 7ad76b1409..789b5574ea 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -92,9 +92,6 @@ class KeychainHandler: NSObject { keyData: keyData, includeAccessGroup: true ) - if addStatus == .success { - try? _deleteKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false) - } return addStatus } catch { throw KeychainHandlerError.unhandledError(reason: error) @@ -177,9 +174,6 @@ class KeychainHandler: NSObject { do { let addStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true) - if addStatus == .success { - try? _deleteKeyImpl(keyName: keyName, includeAccessGroup: false) - } } catch { KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)") } diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 682d61bcd2..359b26bb58 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -658,9 +658,9 @@ DC03A48F321B4BE2A5339F05 /* Quiet.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.xcconfig; sourceTree = SOURCE_ROOT; }; DE73D517F18C6E0B941FDFF2 /* Pods-Quiet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet.debug.xcconfig"; path = "Target Support Files/Pods-Quiet/Pods-Quiet.debug.xcconfig"; sourceTree = ""; }; E079692B46015F1D27CDBBC1 /* Quiet.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.debug.xcconfig; sourceTree = SOURCE_ROOT; }; + E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; }; E17A7BE86B5879902AF7BA3A /* QuietNotificationServiceExtension.debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.debug.xcconfig; sourceTree = SOURCE_ROOT; }; E4A82686A95EE5D58359B484 /* QuietNotificationServiceExtension.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = QuietNotificationServiceExtension.release.xcconfig; sourceTree = SOURCE_ROOT; }; - E079692B46015F1D27CDBBC2 /* Quiet.release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Quiet.release.xcconfig; sourceTree = SOURCE_ROOT; }; EB4DC7EB2F5FF6AB00EFD23F /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; EB4DC7F92F608A3300EFD23F /* QuietNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuietNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; EB4DC8152F60912900EFD23F /* AppDelegate+Firebase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "AppDelegate+Firebase.swift"; path = "Quiet/AppDelegate+Firebase.swift"; sourceTree = ""; }; @@ -670,14 +670,14 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - EB611A772F60ADD1000CA1A2 /* Exceptions for "QuietNotificationServiceExtension" folder in "Quiet" target */ = { + EB611A772F60ADD1000CA1A2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( KeychainHelper.swift, ); target = 13B07F861A680F5B00A75B9A /* Quiet */; }; - EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */ = { + EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -687,19 +687,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = { - isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - EB611A772F60ADD1000CA1A2 /* Exceptions for "QuietNotificationServiceExtension" folder in "Quiet" target */, - EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */, - ); - explicitFileTypes = { - }; - explicitFolders = ( - ); - path = QuietNotificationServiceExtension; - sourceTree = ""; - }; + EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB611A772F60ADD1000CA1A2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = QuietNotificationServiceExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -5446,6 +5434,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 45; + DEVELOPMENT_TEAM = CTYKSWN9T4; ENABLE_BITCODE = YES; EXCLUDED_RECURSIVE_SEARCH_PATH_SUBDIRECTORIES = "*.nib *.lproj *.gch *.xcode* *.xcassets *.icon (*) .DS_Store CVS .svn .git .hg *.pbproj *.pbxproj"; FRAMEWORK_SEARCH_PATHS = ( diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index c397844ae8..2b0a99a27a 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -34,6 +34,13 @@ class NotificationService: UNNotificationServiceExtension { private let crypto = NSECryptoService() private var authCache: [URL: NSEAuthService] = [:] + private static func channelName(from channelId: String) -> String { + guard let separatorIndex = channelId.firstIndex(of: "_") else { + return channelId + } + return String(channelId[.. Void @@ -213,10 +220,11 @@ class NotificationService: UNNotificationServiceExtension { content.title = title.isEmpty ? "Quiet" : title content.body = latestMessage.body + let channelName = Self.channelName(from: latestMessage.channelId) if decryptedMessages.count > 1 { - content.subtitle = "\(decryptedMessages.count) new messages" + content.title = "#\(channelName) (\(decryptedMessages.count) new messages)" } else { - content.subtitle = "" + content.title = "#\(channelName)" } os_log( From 34ec11b321f2c10bf0768b55ea7bc85dce75aa08 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Apr 2026 17:14:02 -0400 Subject: [PATCH 36/92] use monotonic sequence for log pulling, pause qss when minimized --- .../connections-manager.service.spec.ts | 20 ++ .../connections-manager.service.ts | 4 +- .../nest/local-db/local-db.service.spec.ts | 46 ++--- .../src/nest/local-db/local-db.service.ts | 20 +- .../src/nest/local-db/local-db.types.ts | 2 +- .../backend/src/nest/qss/qss.service.spec.ts | 104 +++++++--- packages/backend/src/nest/qss/qss.service.ts | 148 +++++++++++--- packages/backend/src/nest/qss/qss.types.ts | 10 +- packages/mobile/ios/CommunicationBridge.m | 3 +- packages/mobile/ios/CommunicationModule.swift | 35 +++- .../NSEAuthService.swift | 21 +- .../NSEKeychainHelper.swift | 35 +--- .../NSEModels.swift | 5 +- .../NSENetworkClient.swift | 4 +- .../NotificationService.swift | 183 ++++++++---------- packages/mobile/src/setupTests.tsx | 2 +- .../startConnection/startConnection.saga.ts | 21 +- packages/types/src/keys.ts | 4 +- packages/types/src/socket.ts | 6 +- 19 files changed, 418 insertions(+), 255 deletions(-) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index c1d936f9e1..bdbfdc0259 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -18,6 +18,7 @@ import { createLogger } from '../common/logger' import { SigChainService } from '../auth/sigchain.service' import { StorageModule } from '../storage/storage.module' import { QSSService } from '../qss/qss.service' +import { QSSOperationResult } from '../qss/qss.types' const logger = createLogger('connections-manager.service.spec') @@ -164,4 +165,23 @@ describe('ConnectionsManagerService', () => { }) } }) + + it('pauses and resumes qss alongside the mobile lifecycle', async () => { + const closeSocketSpy = jest.spyOn(connectionsManagerService, 'closeSocket').mockResolvedValue() + const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue() + const libp2pPauseSpy = jest.spyOn(connectionsManagerService.libp2pService, 'pause').mockResolvedValue(true) + const libp2pResumeSpy = jest.spyOn(connectionsManagerService.libp2pService, 'resume').mockResolvedValue(true) + const qssPauseSpy = jest.spyOn(qssService, 'pause').mockImplementation(() => {}) + const qssResumeSpy = jest.spyOn(qssService, 'resume').mockResolvedValue(QSSOperationResult.SUCCESS) + + await connectionsManagerService.pause() + expect(qssPauseSpy).toHaveBeenCalledTimes(1) + expect(closeSocketSpy).toHaveBeenCalledTimes(1) + expect(libp2pPauseSpy).toHaveBeenCalledTimes(1) + + await connectionsManagerService.resume() + expect(openSocketSpy).toHaveBeenCalledTimes(1) + expect(libp2pResumeSpy).toHaveBeenCalledTimes(1) + expect(qssResumeSpy).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 3bd195c887..8d224297a0 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -246,15 +246,17 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI public async pause() { this.logger.info('Pausing!') + this.qssService.pause() + await this.libp2pService?.pause() await this.closeSocket() this.logger.info('Pausing libp2pService!') - await this.libp2pService?.pause() } public async resume() { this.logger.info('Resuming!') await this.openSocket() this.libp2pService?.resume() + await this.qssService.resume() } // This method is only used on iOS through rn-bridge for reacting on lifecycle changes diff --git a/packages/backend/src/nest/local-db/local-db.service.spec.ts b/packages/backend/src/nest/local-db/local-db.service.spec.ts index a37766d3c1..ca47d758d3 100644 --- a/packages/backend/src/nest/local-db/local-db.service.spec.ts +++ b/packages/backend/src/nest/local-db/local-db.service.spec.ts @@ -527,54 +527,54 @@ describe('LocalDbService', () => { }) }) - describe('last sync time', () => { + describe('last sync seq', () => { const TEAM_ID = 'team-abc' afterEach(async () => { await service.purge() }) - it('set / get last sync time', async () => { - const timestamp = Date.now() - await service.setLastSyncTime(TEAM_ID, timestamp) - const retrieved = await service.getLastSyncTime(TEAM_ID) - expect(retrieved).toBe(timestamp) + it('set / get last sync seq', async () => { + const syncSeq = 42 + await service.setLastSyncSeq(TEAM_ID, syncSeq) + const retrieved = await service.getLastSyncSeq(TEAM_ID) + expect(retrieved).toBe(syncSeq) }) - it('returns null when no timestamp exists', async () => { - const result = await service.getLastSyncTime('nonexistent-team') + it('returns null when no sync seq exists', async () => { + const result = await service.getLastSyncSeq('nonexistent-team') expect(result).toBeNull() }) it('returns null for invalid (NaN) stored value', async () => { // Manually store invalid value - await service.put(`${LocalDBKeys.LAST_QSS_LOG_SYNC_TIME}:${TEAM_ID}`, 'not-a-number') - const result = await service.getLastSyncTime(TEAM_ID) + await service.put(`${LocalDBKeys.LAST_QSS_LOG_SYNC_SEQ}:${TEAM_ID}`, 'not-a-number') + const result = await service.getLastSyncSeq(TEAM_ID) expect(result).toBeNull() }) it('handles multiple teams independently', async () => { const team1 = 'team-1' const team2 = 'team-2' - const ts1 = 1000 - const ts2 = 2000 + const seq1 = 1000 + const seq2 = 2000 - await service.setLastSyncTime(team1, ts1) - await service.setLastSyncTime(team2, ts2) + await service.setLastSyncSeq(team1, seq1) + await service.setLastSyncSeq(team2, seq2) - expect(await service.getLastSyncTime(team1)).toBe(ts1) - expect(await service.getLastSyncTime(team2)).toBe(ts2) + expect(await service.getLastSyncSeq(team1)).toBe(seq1) + expect(await service.getLastSyncSeq(team2)).toBe(seq2) }) - it('overwrites existing timestamp', async () => { - const ts1 = 1000 - const ts2 = 2000 + it('overwrites existing sync seq', async () => { + const seq1 = 1000 + const seq2 = 2000 - await service.setLastSyncTime(TEAM_ID, ts1) - expect(await service.getLastSyncTime(TEAM_ID)).toBe(ts1) + await service.setLastSyncSeq(TEAM_ID, seq1) + expect(await service.getLastSyncSeq(TEAM_ID)).toBe(seq1) - await service.setLastSyncTime(TEAM_ID, ts2) - expect(await service.getLastSyncTime(TEAM_ID)).toBe(ts2) + await service.setLastSyncSeq(TEAM_ID, seq2) + expect(await service.getLastSyncSeq(TEAM_ID)).toBe(seq2) }) }) diff --git a/packages/backend/src/nest/local-db/local-db.service.ts b/packages/backend/src/nest/local-db/local-db.service.ts index d1209bcb12..8bc3ff6bf0 100644 --- a/packages/backend/src/nest/local-db/local-db.service.ts +++ b/packages/backend/src/nest/local-db/local-db.service.ts @@ -436,25 +436,25 @@ export class LocalDbService extends EventEmitter { } /** - * Set the last QSS log sync time for a given team + * Set the last QSS log sync seq for a given team * @param teamId string team id - * @param timestamp number timestamp in milliseconds + * @param syncSeq number sequence number */ - public async setLastSyncTime(teamId: string, timestamp: number): Promise { - await this.put(`${LocalDBKeys.LAST_QSS_LOG_SYNC_TIME}:${teamId}`, timestamp.toString()) + public async setLastSyncSeq(teamId: string, syncSeq: number): Promise { + await this.put(`${LocalDBKeys.LAST_QSS_LOG_SYNC_SEQ}:${teamId}`, syncSeq.toString()) } /** - * Get the last QSS log sync time for a given team + * Get the last QSS log sync seq for a given team * @param teamId string team id - * @returns number | null timestamp in milliseconds or null if not found + * @returns number | null sequence number or null if not found */ - public async getLastSyncTime(teamId: string): Promise { - const ts = await this.get(`${LocalDBKeys.LAST_QSS_LOG_SYNC_TIME}:${teamId}`) - if (ts === null) { + public async getLastSyncSeq(teamId: string): Promise { + const seq = await this.get(`${LocalDBKeys.LAST_QSS_LOG_SYNC_SEQ}:${teamId}`) + if (seq === null) { return null } - const num = Number(ts) + const num = Number(seq) return isNaN(num) ? null : num } diff --git a/packages/backend/src/nest/local-db/local-db.types.ts b/packages/backend/src/nest/local-db/local-db.types.ts index 1ddf242ae5..b11ac33a03 100644 --- a/packages/backend/src/nest/local-db/local-db.types.ts +++ b/packages/backend/src/nest/local-db/local-db.types.ts @@ -46,7 +46,7 @@ export enum LocalDBKeys { KEYRINGS = 'keyrings', PENDING_HEADS = 'pendingHeads', PENDING_QSS_LOG_SYNCS = 'pendingQssLogSyncs', - LAST_QSS_LOG_SYNC_TIME = 'lastQssLogSyncTime', + LAST_QSS_LOG_SYNC_SEQ = 'lastQssLogSyncSeq', DLQ_DECRYPT = 'dlq:decrypt', DLQ_DECRYPT_IDX = 'dlq:idx', } diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index 31e5dff651..42531460a5 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -16,6 +16,7 @@ import { CreateCommunityStatus, CommunitySignInMessage, LogEntrySyncMessage, + LogEntrySyncResponseMessage, LogEntryPullResponseMessage, QSSOperationResult, } from './qss.types' @@ -290,7 +291,7 @@ describe('QSSService', () => { true ) }) - expect(mockedSendMessage).toHaveBeenCalledTimes(3) + expect(mockedSendMessage.mock.calls.length).toBeGreaterThanOrEqual(2) expect(created).toBeTruthy() const initStatus = await qssService.getQssInitStatus() expect(initStatus.qssSetup).toBeTruthy() @@ -620,6 +621,8 @@ describe('QSSService', () => { await initCommunity({ qssEnabled: true, qssSetup: true }) const initStatusOrig = await qssService.getQssInitStatus() expect(initStatusOrig.qssSetup).toBeTruthy() + const syncSeq = 41 + await localDbService.setLastSyncSeq(sigchainService.team.id, 40) mockedJoinStatus = jest.spyOn(qssService, 'joinStatus').mockReturnValue(JoinStatus.JOINED) mockedSendMessage = jest @@ -640,8 +643,9 @@ describe('QSSService', () => { teamId, hash, hashedDbId, + syncSeq, }, - } as T + } as LogEntrySyncResponseMessage as T default: return undefined } @@ -686,11 +690,53 @@ describe('QSSService', () => { }) expect(result).toBe(true) expect(mockedSendMessage).toHaveBeenCalledTimes(1) + expect(await localDbService.getLastSyncSeq(sigchainService.team.id)).toBe(syncSeq) const pendingMessages = await localDbService.getPendingQssLogSyncMessages() expect(pendingMessages).toEqual({}) }) + it(`updates last sync seq from contiguous fanout`, async () => { + await initCommunity({ qssEnabled: true, qssSetup: true }) + const teamId = sigchainService.activeChain.team!.id + await localDbService.setLastSyncSeq(teamId, 9) + const syncSeq = 10 + + jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(true) + + qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + payload: { + teamId, + hash: 'fanout-hash', + hashedDbId: 'fanout-db-id', + encEntry: { + encrypted: { + contents: new Uint8Array(), + scope: { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + generation: 1, + }, + }, + signature: { + signature: 'fanout-sig' as Base58, + author: { type: 'USER', name: 'fanout-user' } as any, + }, + ts: DateTime.utc().toMillis(), + userId: sigchainService.user.userId, + teamId, + }, + syncSeq, + }, + } satisfies LogEntrySyncMessage) + + await waitForExpect(async () => { + expect(await localDbService.getLastSyncSeq(teamId)).toBe(syncSeq) + }) + }) + it(`fails to send log sync to QSS and writes pending message to local DB`, async () => { await initCommunity({ qssEnabled: true, qssSetup: true }) const initStatusOrig = await qssService.getQssInitStatus() @@ -758,9 +804,6 @@ describe('QSSService', () => { beforeEach(async () => { await initCommunity({ qssEnabled: true, qssSetup: true }) - mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) - await qssService.connect('ws://localhost:3000') - expect(qssService.connected).toBeTruthy() // @ts-ignore mockedPullLogEntries = jest.spyOn(qssService, 'pullLogEntries') }) @@ -773,6 +816,8 @@ describe('QSSService', () => { const teamId = sigchainService.activeChain.team!.id const entriesPage1 = [{ data: 'entry1' }, { data: 'entry2' }] const entriesPage2 = [{ data: 'entry3' }] + const page1SyncSeq = 11 + const page2SyncSeq = 12 mockedPullLogEntries .mockResolvedValueOnce({ ts: DateTime.utc().toMillis(), @@ -780,7 +825,8 @@ describe('QSSService', () => { payload: { entries: entriesPage1, hasNextPage: true, - cursor: 'cursor1', + highestSyncSeq: page1SyncSeq, + resolvedStartSeq: 10, }, }) .mockResolvedValueOnce({ @@ -789,7 +835,8 @@ describe('QSSService', () => { payload: { entries: entriesPage2, hasNextPage: false, - cursor: undefined, + highestSyncSeq: page2SyncSeq, + resolvedStartSeq: page1SyncSeq, }, }) @@ -798,6 +845,8 @@ describe('QSSService', () => { expect(response.status).toBe(CommunityOperationStatus.SUCCESS) expect(response.payload.entries).toEqual([]) expect(response.payload.hasNextPage).toBe(false) + expect(response.payload.highestSyncSeq).toBe(page2SyncSeq) + expect(await localDbService.getLastSyncSeq(teamId)).toBe(page2SyncSeq) }) it('handles empty entries and no next page', async () => { @@ -808,7 +857,7 @@ describe('QSSService', () => { payload: { entries: [], hasNextPage: false, - cursor: undefined, + resolvedStartSeq: 0, }, }) const response = await qssService.pullLatestLogEntries(teamId) @@ -848,7 +897,7 @@ describe('QSSService', () => { payload: { entries: [serializer.serialize(mockEncryptedPayload)], hasNextPage: false, - cursor: undefined, + resolvedStartSeq: 0, }, }) @@ -905,20 +954,16 @@ describe('QSSService', () => { payload: { entries: [], hasNextPage: false }, }) - // Start interval - qssService.startLogPullInterval(teamId) + const interval = setInterval(() => undefined, 30_000) + // @ts-ignore - seed the interval map to verify _pullLatestLogEntriesForTeam stops it on success + qssService._logPullIntervals.set(teamId, interval) - // Verify interval was created - // @ts-ignore - accessing private property for testing - expect(qssService._logPullIntervals.has(teamId)).toBe(true) - - // Wait for pull to complete and interval to be stopped - await waitForExpect(() => { - // @ts-ignore - expect(qssService._logPullIntervals.has(teamId)).toBe(false) - }) + // @ts-ignore + await qssService._pullLatestLogEntriesForTeam(teamId) expect(mockedPullLogEntries).toHaveBeenCalledTimes(1) + // @ts-ignore + expect(qssService._logPullIntervals.has(teamId)).toBe(false) }) it('retries log pull interval on failure', async () => { @@ -936,25 +981,24 @@ describe('QSSService', () => { payload: { entries: [], hasNextPage: false }, }) - // Start interval - this immediately triggers first pull - qssService.startLogPullInterval(teamId) + const interval = setInterval(() => undefined, 30_000) + // @ts-ignore - seed the interval map to verify failure keeps it alive and success clears it + qssService._logPullIntervals.set(teamId, interval) - // Wait for first (immediate) pull to complete - await waitForExpect(() => { - expect(mockedPullLogEntries).toHaveBeenCalledTimes(1) - }) + // @ts-ignore + await qssService._pullLatestLogEntriesForTeam(teamId) // Interval should still exist after failed pull + expect(mockedPullLogEntries).toHaveBeenCalledTimes(1) // @ts-ignore expect(qssService._logPullIntervals.has(teamId)).toBe(true) - // Manually trigger second pull (simulating interval firing) // @ts-ignore await qssService._pullLatestLogEntriesForTeam(teamId) expect(mockedPullLogEntries).toHaveBeenCalledTimes(2) - // Interval should be stopped after successful pull + // Interval should stop after successful pull // @ts-ignore expect(qssService._logPullIntervals.has(teamId)).toBe(false) }) @@ -975,7 +1019,7 @@ describe('QSSService', () => { qssService.pullLogEntries({ teamId, userId: sigchainService.user.userId, - startTs: 0, + startSeq: 0, }) ).rejects.toThrow('Nullish response from QSS') }) @@ -995,7 +1039,7 @@ describe('QSSService', () => { const result = await qssService.pullLogEntries({ teamId, userId: sigchainService.user.userId, - startTs: 0, + startSeq: 0, }) expect(result.payload.entries.length).toBe(1) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 856510c124..e0588aed0f 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -17,6 +17,7 @@ import { CreateCommunityResponse, CreateCommunityStatus, LogEntrySyncMessage, + LogEntrySyncResponseMessage, GeneratePublicKeysMessage, WebsocketEvents, QSSOperationResult, @@ -42,19 +43,14 @@ import { DLQDecryptEntry } from '../local-db/local-db.types' import { LogUpdate } from '../storage/orbitDb/orbitdb.types' import { logEntryToLogUpdate } from '../storage/orbitDb/util' import { QSS_RECONNECT_DELAY_MS } from './qss.const' -import { - CompoundError, - InvitationDataV3, - NseSyncTimestampUpdatedEvent, - SocketActions, - SocketEvents, -} from '@quiet/types' +import { CompoundError, InvitationDataV3, NseSyncSeqUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types' import { LocalDbEvents } from '../local-db/local-db.types' import { SocketService } from '../socket/socket.service' import { Serializer } from '../common/serializer.service' @Injectable() export class QSSService extends EventEmitter implements OnModuleDestroy, OnModuleInit { + private _paused = false /** * True while waiting for websocket connection to finish connecting */ @@ -66,7 +62,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul /** * Interval for retrying/reconnecting to QSS */ - private _reconnectQueueProcessor: NodeJS.Timeout + private _reconnectQueueProcessor: NodeJS.Timeout | undefined /** * Map of team IDs to intervals pulling log entries @@ -212,8 +208,13 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.qssClient.on(WebsocketEvents.LOG_ENTRY_SYNC, async (message: LogEntrySyncMessage): Promise => { this.logger.debug('Forwarding fanout log entry sync message to OrbitDB service') const ingested = await this.orbitDbService.handleFanoutMessage(message) - if (ingested) { - await this.updateNseLastSyncTimestamp(message.payload.teamId, message.ts) + if (message.payload.syncSeq != null) { + await this.handleObservedSyncSeq( + message.payload.teamId, + message.payload.syncSeq, + ingested, + `fanout hash=${message.payload.hash}` + ) } }) @@ -337,6 +338,11 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul } public async connect(qssEndpoint: string | undefined, enabledOverride: boolean = false): Promise { + if (this._paused) { + this.logger.debug('Skipping QSS connect because service is paused') + return QSSOperationResult.DISABLED + } + let connStatus: QSSOperationResult try { connStatus = await this._connectImpl(qssEndpoint, enabledOverride) @@ -352,6 +358,27 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul return connStatus } + public pause(): void { + this.logger.info('Pausing QSS service') + this._paused = true + if (this._reconnectQueueProcessor != null) { + clearInterval(this._reconnectQueueProcessor) + this._reconnectQueueProcessor = undefined + } + for (const interval of this._logPullIntervals.values()) { + clearInterval(interval) + } + this._logPullIntervals.clear() + this.qssAuthConnManager.close() + this.qssClient.close() + } + + public async resume(): Promise { + this.logger.info('Resuming QSS service') + this._paused = false + return await this.connect(this.qssEndpoint) + } + /** * Connect the QSS client if enabled * @@ -580,13 +607,12 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul return } - void this._pullLatestLogEntriesForTeam(teamId) - const interval = setInterval(() => { void this._pullLatestLogEntriesForTeam(teamId) }, 30_000) this._logPullIntervals.set(teamId, interval) + void this._pullLatestLogEntriesForTeam(teamId) } /** @@ -605,7 +631,6 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul result = QSSOperationResult.ERROR } - // TODO: cleanup the connected listener if (result === QSSOperationResult.SUCCESS) { this.logger.info('Successfully signed in to QSS, starting periodic log pulls once connected', teamId) const authConnection = this.qssAuthConnManager.getConnection(teamId) @@ -618,6 +643,8 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.startLogPullInterval(teamId) } + authConnection?.removeAllListeners(QSSEvents.QSS_AUTH_CONNECTED) + authConnection?.removeAllListeners(QSSEvents.QSS_DISCONNECTED) authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, () => { this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED) startLogPullInterval() @@ -779,7 +806,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul } this.logger.debug('Sending log sync message to QSS', hash, teamId) - const dataSyncAck = await this.qssClient.sendMessage( + const dataSyncAck = await this.qssClient.sendMessage( WebsocketEvents.LOG_ENTRY_SYNC, dataSyncMessage, true @@ -792,6 +819,9 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.logger.error(`Error while sending a log sync to QSS - ${dataSyncAck.reason}`, hash, teamId) } else { this.logger.debug('Successful log sync to QSS') + if (dataSyncAck.payload.syncSeq != null) { + await this.handleObservedSyncSeq(teamId, dataSyncAck.payload.syncSeq, true, `sync-ack hash=${hash}`) + } success = true this.qssClient.emit(QSSEvents.QSS_LOG_SYNCED, dataSyncMessage.payload!.teamId) } @@ -840,26 +870,30 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul public async pullLatestLogEntries(teamId: string): Promise { this.logger.info(`Pulling all log entries from QSS for team ${teamId}`) - const lastSyncTime = await this.localDbService.getLastSyncTime(teamId) + let nextStartSeq = await this.localDbService.getLastSyncSeq(teamId) const sigchain = this.sigChainService.getChain({ teamId }) const userId = sigchain.context.user.userId let hasNextPage = true let page = 0 - let cursor: string | undefined = undefined + let highestSyncSeq: number | undefined = nextStartSeq ?? undefined while (hasNextPage) { const pullPayload: LogEntryPullPayload = { teamId, userId, - startTs: lastSyncTime ?? 0, - cursor, + ...(nextStartSeq != null ? { startSeq: nextStartSeq } : { startSeq: 0 }), } this.logger.info(`Pulling log entries page ${page} from QSS for team ${teamId}`) - const newSyncTime = DateTime.utc().toMillis() const pullResponse = await this.pullLogEntries(pullPayload) if (pullResponse.status !== CommunityOperationStatus.SUCCESS) { return pullResponse } + if (pullResponse.payload.highestSyncSeq != null) { + highestSyncSeq = + highestSyncSeq == null + ? pullResponse.payload.highestSyncSeq + : Math.max(highestSyncSeq, pullResponse.payload.highestSyncSeq) + } const deserializedEntries = pullResponse.payload.entries .map(entry => { try { @@ -904,13 +938,15 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul try { await this.orbitDbService.ingestEntries(decryptedEntries) - await this.updateNseLastSyncTimestamp(teamId, newSyncTime) + if (pullResponse.payload.highestSyncSeq != null) { + nextStartSeq = pullResponse.payload.highestSyncSeq + await this.updateLastSyncSeq(teamId, pullResponse.payload.highestSyncSeq) + } } catch (e) { this.logger.error('Failed to ingest pulled log entries from QSS into OrbitDB', e) + throw e } hasNextPage = pullResponse.payload.hasNextPage - - cursor = pullResponse.payload.cursor page += 1 } const finalPullResponse: LogEntryPullResponseMessage = { @@ -919,31 +955,75 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul payload: { entries: [], hasNextPage: false, + highestSyncSeq, + resolvedStartSeq: nextStartSeq ?? undefined, }, } this.logger.info(`Completed pulling all log entries from QSS for team ${teamId}`) return finalPullResponse } - private async updateNseLastSyncTimestamp(teamId: string, timestamp: number): Promise { - if (!Number.isFinite(timestamp) || timestamp <= 0) { - this.logger.warn(`Refusing to persist invalid NSE sync timestamp for team ${teamId}: ${timestamp}`) + private async handleObservedSyncSeq( + teamId: string, + syncSeq: number, + ingested: boolean, + source: string + ): Promise { + if (!Number.isFinite(syncSeq) || syncSeq <= 0) { + this.logger.warn(`Refusing to handle invalid sync seq for team ${teamId}: ${syncSeq} (${source})`) return } - const existingTimestamp = await this.localDbService.getLastSyncTime(teamId) - const nextTimestamp = existingTimestamp == null ? timestamp : Math.max(existingTimestamp, timestamp) + const existingSeq = await this.localDbService.getLastSyncSeq(teamId) - if (existingTimestamp === nextTimestamp) { + if (!ingested) { + this.logger.warn( + `Observed sync seq ${syncSeq} for ${teamId} from ${source} but local ingest failed; reconciling by pull` + ) + void this._pullLatestLogEntriesForTeam(teamId) + return + } + + if (existingSeq == null) { + this.logger.debug(`No persisted sync seq for ${teamId}; establishing baseline via pull before advancing seq`) + void this._pullLatestLogEntriesForTeam(teamId) + return + } + + if (syncSeq <= existingSeq) { + return + } + + if (syncSeq !== existingSeq + 1) { + this.logger.warn( + `Detected sync seq gap for ${teamId}: existing=${existingSeq} observed=${syncSeq} source=${source}; pulling reconciliation` + ) + void this._pullLatestLogEntriesForTeam(teamId) + return + } + + await this.updateLastSyncSeq(teamId, syncSeq) + } + + private async updateLastSyncSeq(teamId: string, syncSeq: number): Promise { + if (!Number.isFinite(syncSeq) || syncSeq <= 0) { + this.logger.warn(`Refusing to persist invalid sync seq for team ${teamId}: ${syncSeq}`) + return + } + + const existingSeq = await this.localDbService.getLastSyncSeq(teamId) + const nextSyncSeq = existingSeq == null ? syncSeq : Math.max(existingSeq, syncSeq) + + if (existingSeq === nextSyncSeq) { return } - await this.localDbService.setLastSyncTime(teamId, nextTimestamp) - const payload: NseSyncTimestampUpdatedEvent = { + await this.localDbService.setLastSyncSeq(teamId, nextSyncSeq) + const payload: NseSyncSeqUpdatedEvent = { teamId, - lastSyncTimestamp: nextTimestamp, + lastSyncSeq: nextSyncSeq, } - this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, payload) + this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_SYNC_SEQ_UPDATED, payload) } /** @@ -1033,6 +1113,10 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul public close(): void { this.logger.info(`Closing QSS service`) clearInterval(this._deadLetterQueueProcessor) + if (this._reconnectQueueProcessor != null) { + clearInterval(this._reconnectQueueProcessor) + this._reconnectQueueProcessor = undefined + } for (const interval of this._logPullIntervals.values()) { clearInterval(interval) } diff --git a/packages/backend/src/nest/qss/qss.types.ts b/packages/backend/src/nest/qss/qss.types.ts index 29c0512432..2c7cb82f29 100644 --- a/packages/backend/src/nest/qss/qss.types.ts +++ b/packages/backend/src/nest/qss/qss.types.ts @@ -160,6 +160,8 @@ export interface LogEntrySyncPayload { hash: string hashedDbId: string encEntry: EncryptedAndSignedPayload + receivedAt?: number + syncSeq?: number } export interface LogEntrySyncMessage extends BaseWebsocketMessage { @@ -173,6 +175,8 @@ export interface LogEntrySyncResponsePayload { teamId: string hash: string hashedDbId: string + receivedAt?: number + syncSeq?: number } export interface LogEntrySyncResponseMessage extends BaseWebsocketMessage { @@ -186,12 +190,13 @@ export interface LogEntryPullPayload { teamId: string userId: string direction?: 'forward' | 'backward' + startSeq?: number + endSeq?: number startTs?: number endTs?: number limit?: number hash?: string hashedDbId?: string - cursor?: string } export interface LogEntryPullMessage extends BaseWebsocketMessage { @@ -202,9 +207,10 @@ export interface LogEntryPullMessage extends BaseWebsocketMessage { diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 7b0f5abbba..985581698f 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -9,5 +9,6 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(saveUserMetadata:(NSArray *)updatedMetadata) RCT_EXTERN_METHOD(saveDeviceCredentials:(NSString *)deviceId teamId:(NSString *)teamId signingPrivateKey:(NSString *)signingPrivateKey) RCT_EXTERN_METHOD(saveNseQssUrl:(NSString *)teamId qssUrl:(NSString *)qssUrl) -RCT_EXTERN_METHOD(saveNseLastSyncTimestamp:(nonnull NSNumber *)timestamp) +RCT_EXTERN_METHOD(saveNseLastSyncSeq:(NSString *)teamId + syncSeq:(nonnull NSNumber *)syncSeq) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 49ed5e99e8..b32b2a02f0 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -12,7 +12,8 @@ class CommunicationModule: RCTEventEmitter { static let APP_RESUME_IDENTIFIER = "appresume" static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult" static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" - static let NSE_LAST_SYNC_KEY = "quiet.nse.lastSyncTimestamp" + static let NSE_LAST_SYNC_SEQ_KEY = "quiet.nse.lastSyncSeq" + static let NSE_LAST_SYNC_TEAM_ID_KEY = "quiet.nse.lastSyncTeamId" static let NSE_QSS_URLS_KEY = "quiet.nse.qssUrls" static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount" static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground" @@ -49,7 +50,15 @@ class CommunicationModule: RCTEventEmitter { defaults.set(true, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY) defaults.set(0, forKey: CommunicationModule.NSE_BADGE_COUNT_KEY) UNUserNotificationCenter.current().removeAllDeliveredNotifications() - UIApplication.shared.applicationIconBadgeNumber = 0 + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) { error in + if let error { + CommunicationModule.logger.error("appResume: failed to clear badge count: \(error)") + } + } + } else { + UIApplication.shared.applicationIconBadgeNumber = 0 + } self.sendEvent(withName: CommunicationModule.APP_RESUME_IDENTIFIER, body: nil) } @@ -84,7 +93,7 @@ class CommunicationModule: RCTEventEmitter { let keyAsString: String = keyAsAny as! String let data = Data(keyAsString.utf8) let decodedNamedKey = try decoder.decode(NamedKey.self, from: data) - try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey) + _ = try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey) let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName) CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)") } catch { @@ -178,20 +187,25 @@ class CommunicationModule: RCTEventEmitter { } @objc - func saveNseLastSyncTimestamp(_ timestamp: NSNumber) { + func saveNseLastSyncSeq( + _ teamId: NSString, + syncSeq: NSNumber + ) { let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - let newTimestamp = timestamp.doubleValue - let existingTimestamp = defaults.double(forKey: CommunicationModule.NSE_LAST_SYNC_KEY) + let newSyncSeq = syncSeq.doubleValue + let existingSyncSeq = defaults.double(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) + let teamIdStr = teamId as String - if existingTimestamp >= newTimestamp { + if existingSyncSeq >= newSyncSeq { CommunicationModule.logger.debug( - "saveNseLastSyncTimestamp: ignoring stale timestamp \(newTimestamp, privacy: .public), existing=\(existingTimestamp, privacy: .public)" + "saveNseLastSyncSeq: ignoring stale seq \(newSyncSeq, privacy: .public), existing=\(existingSyncSeq, privacy: .public)" ) return } - defaults.set(newTimestamp, forKey: CommunicationModule.NSE_LAST_SYNC_KEY) - CommunicationModule.logger.info("saveNseLastSyncTimestamp: stored \(newTimestamp, privacy: .public)") + defaults.set(newSyncSeq, forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) + defaults.set(teamIdStr, forKey: CommunicationModule.NSE_LAST_SYNC_TEAM_ID_KEY) + CommunicationModule.logger.info("saveNseLastSyncSeq: stored \(newSyncSeq, privacy: .public)") } @objc @@ -249,4 +263,5 @@ class CommunicationModule: RCTEventEmitter { CommunicationModule.DEVICE_TOKEN_RECEIVED ] } + } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift index 261c0bfce3..0e5e9f2319 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift @@ -49,15 +49,26 @@ class NSEAuthService { // MARK: - Fetch log entries - func fetchNewEntries(teamId: String, since: Int64) async throws -> [LogEntry] { + func fetchNewEntries(teamId: String, afterSeq: Int64) async throws -> LogEntriesResponse { os_log("fetchNewEntries: reading deviceId from keychain", log: authLog, type: .debug) let deviceId = try NSEKeychainHelper.getDeviceId() os_log("fetchNewEntries: deviceId=%{public}@, authenticating", log: authLog, type: .info, deviceId) let token = try await authenticate(deviceId: deviceId, teamId: teamId) - os_log("fetchNewEntries: authenticated, fetching log entries since=%{public}lld", log: authLog, type: .info, since) - let resp = try await client.fetchLogEntries(teamId: teamId, since: since, token: token) - os_log("fetchNewEntries: received %{public}d entries", log: authLog, type: .info, resp.entries.count) - return resp.entries + os_log("fetchNewEntries: authenticated, fetching log entries afterSeq=%{public}lld", + log: authLog, type: .info, afterSeq) + do { + let resp = try await client.fetchLogEntries(teamId: teamId, afterSeq: afterSeq, token: token) + os_log("fetchNewEntries: received %{public}d entries", log: authLog, type: .info, resp.entries.count) + return resp + } catch NSEAuthError.logFetchFailed(let statusCode) where statusCode == 401 { + os_log("fetchNewEntries: token rejected (401) for teamId=%{public}@, evicting cache and retrying", + log: authLog, type: .info, teamId) + tokenCache.removeValue(forKey: teamId) + let freshToken = try await authenticate(deviceId: deviceId, teamId: teamId) + let resp = try await client.fetchLogEntries(teamId: teamId, afterSeq: afterSeq, token: freshToken) + os_log("fetchNewEntries: retry succeeded, received %{public}d entries", log: authLog, type: .info, resp.entries.count) + return resp + } } } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index b91ed2634e..89adf29508 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -9,8 +9,7 @@ struct NSEKeychainHelper { private static let devicePrivateKeyPrefix = "quiet.device.privateKey." private static let deviceIdKey = "quiet.device.id" private static let teamIdKey = "quiet.team.id" - private static let lastSyncKey = "quiet.nse.lastSyncTimestamp" - private static let lastSyncCidsKey = "quiet.nse.lastSyncCids" + private static let lastSyncSeqKey = "quiet.nse.lastSyncSeq" private static let qssUrlsKey = "quiet.nse.qssUrls" private static let appIsForegroundKey = "quiet.app.isForeground" private static let lfaKeyService = "com.quietmobile" @@ -56,36 +55,18 @@ struct NSEKeychainHelper { return str } - // MARK: - Last sync timestamp (UserDefaults — not sensitive) + // MARK: - Last sync state (UserDefaults — not sensitive) - static func getLastSyncTimestamp() -> Int64 { + static func getLastSyncSeq() -> Int64 { let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard - return Int64(defaults.double(forKey: lastSyncKey)) + return Int64(defaults.double(forKey: lastSyncSeqKey)) } - static func saveLastSyncTimestamp(_ ts: Int64) { + static func saveLastSyncSeq(_ seq: Int64) { let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard - let current = Int64(defaults.double(forKey: lastSyncKey)) - os_log("Saving last sync timestamp: current=%{public}lld, new=%{public}lld", current, ts) - defaults.set(Double(max(current, ts)), forKey: lastSyncKey) - } - - static func getLastSyncCids() -> [String] { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard - return defaults.stringArray(forKey: lastSyncCidsKey) ?? [] - } - - static func saveLastSyncState(timestamp: Int64, cids: [String]) { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard - let currentTimestamp = Int64(defaults.double(forKey: lastSyncKey)) - if timestamp < currentTimestamp { - os_log("Ignoring stale sync state save: current=%{public}lld, new=%{public}lld", currentTimestamp, timestamp) - return - } - - defaults.set(Double(timestamp), forKey: lastSyncKey) - defaults.set(Array(Set(cids)).sorted(), forKey: lastSyncCidsKey) - os_log("Saved sync state: timestamp=%{public}lld cids=%{public}@", timestamp, cids.joined(separator: ",")) + let current = Int64(defaults.double(forKey: lastSyncSeqKey)) + os_log("Saving last sync seq: current=%{public}lld, new=%{public}lld", current, seq) + defaults.set(Double(max(current, seq)), forKey: lastSyncSeqKey) } static func getQssUrl(teamId: String) -> URL? { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift index 2221257c78..7e03c083ce 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift @@ -69,9 +69,10 @@ struct LogEntry: Decodable { let communityId: String // Team ID let entry: Data // Raw EncryptedAndSignedPayload bytes let receivedAt: String // ISO 8601 UTC string + let syncSeq: Int64 // Server-assigned per-team sync order private enum CodingKeys: String, CodingKey { - case cid, hashedDbId, communityId, entry, receivedAt + case cid, hashedDbId, communityId, entry, receivedAt, syncSeq } // Node.js Buffer serializes to JSON as {"type":"Buffer","data":[byte,...]} @@ -85,6 +86,7 @@ struct LogEntry: Decodable { hashedDbId = try c.decode(String.self, forKey: .hashedDbId) communityId = try c.decode(String.self, forKey: .communityId) receivedAt = try c.decode(String.self, forKey: .receivedAt) + syncSeq = try c.decode(Int64.self, forKey: .syncSeq) let buffer = try c.decode(NodeBuffer.self, forKey: .entry) entry = Data(buffer.data) } @@ -92,4 +94,5 @@ struct LogEntry: Decodable { struct LogEntriesResponse: Decodable { let entries: [LogEntry] + let resolvedAfterSeq: Int64 } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift index f3b1445113..dd2c1a40b0 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift @@ -63,13 +63,13 @@ class NSENetworkClient { // MARK: - GET /nse-auth/logs/:teamId - func fetchLogEntries(teamId: String, since: Int64, token: String) async throws -> LogEntriesResponse { + func fetchLogEntries(teamId: String, afterSeq: Int64, token: String) async throws -> LogEntriesResponse { guard let urlComponents = URLComponents( url: baseURL.appendingPathComponent("nse-auth/logs/\(teamId)"), resolvingAgainstBaseURL: false ) else { throw NSEAuthError.invalidResponse } var components = urlComponents - components.queryItems = [URLQueryItem(name: "since", value: String(since))] + components.queryItems = [URLQueryItem(name: "afterSeq", value: String(afterSeq))] guard let url = components.url else { throw NSEAuthError.invalidResponse } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 2b0a99a27a..94f911895d 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -14,23 +14,15 @@ class NotificationService: UNNotificationServiceExtension { private static let appGroupIdentifier = "group.com.quietmobile" private static let badgeCountKey = "quiet.nse.badgeCount" - private struct TimedEntry { + private struct DecryptedEntry { let entry: LogEntry - let timestamp: Int64? + let message: NSEDecryptedNotificationMessage } var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? var fetchTask: Task? - // Luxon's toISO() always includes milliseconds (e.g. "2024-03-21T10:00:00.000Z"). - // The default ISO8601DateFormatter does not parse fractional seconds — - // withFractionalSeconds is required or every timestamp parse will fail. - private static let iso8601: ISO8601DateFormatter = { - let f = ISO8601DateFormatter() - f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return f - }() private let crypto = NSECryptoService() private var authCache: [URL: NSEAuthService] = [:] @@ -110,12 +102,15 @@ class NotificationService: UNNotificationServiceExtension { auth = newAuth } - let since = NSEKeychainHelper.getLastSyncTimestamp() - let lastSyncCids = Set(NSEKeychainHelper.getLastSyncCids()) - os_log("fetchAndUpdate: fetching entries since=%{public}lld", log: nseLog, type: .info, since) + let afterSeq = NSEKeychainHelper.getLastSyncSeq() + os_log("fetchAndUpdate: fetching entries afterSeq=%{public}lld", + log: nseLog, type: .info, afterSeq) - let entries = try await auth.fetchNewEntries(teamId: teamId, since: since) - os_log("fetchAndUpdate: fetched %{public}d entries", log: nseLog, type: .info, entries.count) + let response = try await auth.fetchNewEntries(teamId: teamId, afterSeq: afterSeq) + let entries = response.entries + let baselineSeq = afterSeq + os_log("fetchAndUpdate: fetched %{public}d entries", + log: nseLog, type: .info, entries.count) guard !Task.isCancelled else { os_log("fetchAndUpdate: task cancelled after fetch", log: nseLog, type: .info) @@ -125,24 +120,7 @@ class NotificationService: UNNotificationServiceExtension { if entries.isEmpty { os_log("fetchAndUpdate: no new entries, delivering as-is", log: nseLog, type: .info) } else { - let timedEntries = entries.map { entry in - let parsedDate = Self.iso8601.date(from: entry.receivedAt) - let timestamp = parsedDate.map { Int64($0.timeIntervalSince1970 * 1000) } - return TimedEntry(entry: entry, timestamp: timestamp) - } - - let unseenEntries = timedEntries.filter { timedEntry in - guard let timestamp = timedEntry.timestamp else { - return true - } - if timestamp > since { - return true - } - if timestamp < since { - return false - } - return !lastSyncCids.contains(timedEntry.entry.cid) - } + let unseenEntries = entries.filter { $0.syncSeq > baselineSeq } if unseenEntries.isEmpty { os_log("fetchAndUpdate: no unseen entries after cursor filtering", log: nseLog, type: .info) @@ -150,106 +128,117 @@ class NotificationService: UNNotificationServiceExtension { } let sortedEntries = unseenEntries.sorted { lhs, rhs in - let leftTs = lhs.timestamp ?? Int64.min - let rightTs = rhs.timestamp ?? Int64.min - if leftTs != rightTs { - return leftTs < rightTs - } - return lhs.entry.cid < rhs.entry.cid - } - - let isBootstrapSync = since == 0 - let notificationEntries: [TimedEntry] - if isBootstrapSync, let newestTimestamp = sortedEntries.compactMap(\.timestamp).max() { - notificationEntries = sortedEntries.filter { $0.timestamp == newestTimestamp } - os_log( - "fetchAndUpdate: bootstrap sync detected, collapsing %{public}d fetched entries to %{public}d newest entries", - log: nseLog, - type: .info, - sortedEntries.count, - notificationEntries.count - ) - } else { - notificationEntries = sortedEntries + lhs.syncSeq < rhs.syncSeq } - let maxTimestamp = sortedEntries.compactMap(\.timestamp).max() - if let maxTimestamp { - let cidsAtMaxTimestamp = sortedEntries - .filter { $0.timestamp == maxTimestamp } - .map(\.entry.cid) - os_log( - "fetchAndUpdate: saving sync state timestamp=%{public}lld with %{public}d cid(s)", - log: nseLog, - type: .info, - maxTimestamp, - cidsAtMaxTimestamp.count - ) - NSEKeychainHelper.saveLastSyncState(timestamp: maxTimestamp, cids: cidsAtMaxTimestamp) - } else { - // All receivedAt failed to parse — advance by 1ms to avoid reprocessing - os_log("All receivedAt timestamps failed to parse; advancing sync pointer", log: nseLog, type: .fault) - NSEKeychainHelper.saveLastSyncState( - timestamp: NSEKeychainHelper.getLastSyncTimestamp() + 1, - cids: notificationEntries.map(\.entry.cid) - ) - } + let notificationEntries = sortedEntries + let maxSyncSeq = notificationEntries.map(\.syncSeq).max() ?? baselineSeq + os_log( + "fetchAndUpdate: saving sync seq=%{public}lld", + log: nseLog, + type: .info, + maxSyncSeq + ) + NSEKeychainHelper.saveLastSyncSeq(maxSyncSeq) guard let content = bestAttemptContent else { os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error) return } - let decryptedMessages = notificationEntries.compactMap { timedEntry -> NSEDecryptedNotificationMessage? in + let decryptedEntries = notificationEntries.compactMap { entry -> DecryptedEntry? in do { - return try self.crypto.decryptNotificationMessage(from: timedEntry.entry, teamId: teamId) + guard let message = try self.crypto.decryptNotificationMessage(from: entry, teamId: teamId) else { + return nil + } + return DecryptedEntry(entry: entry, message: message) } catch { os_log( "fetchAndUpdate: failed to decrypt entry %{public}@: %{public}@", log: nseLog, type: .error, - timedEntry.entry.cid, + entry.cid, String(describing: error) ) return nil } } - if let latestMessage = decryptedMessages.last { - let title = content.title.trimmingCharacters(in: .whitespacesAndNewlines) - content.title = title.isEmpty ? "Quiet" : title - content.body = latestMessage.body + let badgeIncrement = decryptedEntries.isEmpty ? notificationEntries.count : decryptedEntries.count + let defaults = UserDefaults(suiteName: Self.appGroupIdentifier) ?? UserDefaults.standard + let storedBadgeCount = max(0, defaults.integer(forKey: Self.badgeCountKey)) + let newBadge = storedBadgeCount + badgeIncrement + let badgeNumber = NSNumber(value: newBadge) + os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) + defaults.set(newBadge, forKey: Self.badgeCountKey) - let channelName = Self.channelName(from: latestMessage.channelId) - if decryptedMessages.count > 1 { - content.title = "#\(channelName) (\(decryptedMessages.count) new messages)" - } else { - content.title = "#\(channelName)" + if let latestDecryptedEntry = decryptedEntries.last { + for decryptedEntry in decryptedEntries.dropLast() { + let scheduledContent = self.makeNotificationContent( + from: content, + message: decryptedEntry.message, + badge: badgeNumber + ) + await self.scheduleNotification( + identifier: "quiet.nse.synced.\(decryptedEntry.entry.cid)", + content: scheduledContent + ) } + self.applyNotificationMessage(latestDecryptedEntry.message, to: content) + content.badge = badgeNumber + os_log( - "fetchAndUpdate: updated notification body from decrypted message (count=%{public}d)", + "fetchAndUpdate: emitted %{public}d per-entry notification(s)", log: nseLog, type: .info, - decryptedMessages.count + decryptedEntries.count ) } else { os_log("fetchAndUpdate: no decryptable channel messages found", log: nseLog, type: .info) + content.badge = badgeNumber } - - let badgeIncrement = decryptedMessages.isEmpty ? notificationEntries.count : decryptedMessages.count - let defaults = UserDefaults(suiteName: Self.appGroupIdentifier) ?? UserDefaults.standard - let storedBadgeCount = max(0, defaults.integer(forKey: Self.badgeCountKey)) - let newBadge = storedBadgeCount + badgeIncrement - os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) - defaults.set(newBadge, forKey: Self.badgeCountKey) - content.badge = newBadge as NSNumber } } catch { os_log("fetchAndUpdate failed: %{public}@", log: nseLog, type: .error, String(describing: error)) } } + private func applyNotificationMessage(_ message: NSEDecryptedNotificationMessage, to content: UNMutableNotificationContent) { + content.title = "#\(Self.channelName(from: message.channelId))" + content.body = message.body + content.threadIdentifier = message.channelId + } + + private func makeNotificationContent( + from template: UNNotificationContent, + message: NSEDecryptedNotificationMessage, + badge: NSNumber + ) -> UNMutableNotificationContent { + let content = (template.mutableCopy() as? UNMutableNotificationContent) ?? UNMutableNotificationContent() + applyNotificationMessage(message, to: content) + content.badge = badge + return content + } + + private func scheduleNotification(identifier: String, content: UNNotificationContent) async { + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil) + await withCheckedContinuation { continuation in + UNUserNotificationCenter.current().add(request) { error in + if let error { + os_log( + "fetchAndUpdate: failed to schedule notification %{public}@: %{public}@", + log: nseLog, + type: .error, + identifier, + String(describing: error) + ) + } + continuation.resume() + } + } + } + private func deliver() { guard let handler = contentHandler, let content = bestAttemptContent else { return } // Nil contentHandler first to prevent double-delivery if serviceExtensionTimeWillExpire diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index e989a14cb9..b39f2ecc4d 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -48,7 +48,7 @@ jest.mock('react-native', () => { checkNotificationPermission: jest.fn(), handleIncomingEvents: jest.fn(), saveNseQssUrl: jest.fn(), - saveNseLastSyncTimestamp: jest.fn(), + saveNseLastSyncSeq: jest.fn(), } rn.NativeModules.FirebaseMessagingModule = { getToken: jest.fn(), diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index e271859e0e..1da585edcf 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -21,13 +21,12 @@ import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, - NseSyncTimestampUpdatedEvent, + NseSyncSeqUpdatedEvent, SocketActions, SocketEvents, UserProfilesUpdatedPayload, } from '@quiet/types' import { createLogger } from '../../../utils/logger' -import { initSelectors } from '../init.selectors' import { keysActions } from '../../keys/keys.slice' import { usersMetadataActions } from '../../userMetadata/usersMetadata.slice' @@ -122,15 +121,23 @@ function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnect logger.error('Failed to store NSE QSS URL in iOS native storage', error) } }) - socket.on(SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED, async (payload: NseSyncTimestampUpdatedEvent) => { - logger.info(`NSE sync timestamp updated for team ${payload.teamId}, saving in shared iOS storage`) + socket.on(SocketEvents.NSE_SYNC_SEQ_UPDATED, async (payload: NseSyncSeqUpdatedEvent) => { + logger.info(`NSE sync seq updated for team ${payload.teamId}, saving in shared iOS storage`) try { - await NativeModules.CommunicationModule?.saveNseLastSyncTimestamp?.(payload.lastSyncTimestamp) + await NativeModules.CommunicationModule?.saveNseLastSyncSeq?.(payload.teamId, payload.lastSyncSeq) } catch (error) { - logger.error('Failed to store NSE sync timestamp in iOS native storage', error) + logger.error('Failed to store NSE sync seq in iOS native storage', error) } }) - return () => {} + return () => { + socket.off('connect') + socket.off('disconnect') + socket.off(SocketEvents.KEYS_UPDATED) + socket.off(SocketEvents.DEVICE_CREDENTIALS_UPDATED) + socket.off(SocketEvents.USER_PROFILES_UPDATED) + socket.off(SocketEvents.NSE_QSS_URL_UPDATED) + socket.off(SocketEvents.NSE_SYNC_SEQ_UPDATED) + } }) } diff --git a/packages/types/src/keys.ts b/packages/types/src/keys.ts index 2e558f88d9..ffcdb34671 100644 --- a/packages/types/src/keys.ts +++ b/packages/types/src/keys.ts @@ -21,7 +21,7 @@ export interface NseQssUrlUpdatedEvent { qssUrl: string } -export interface NseSyncTimestampUpdatedEvent { +export interface NseSyncSeqUpdatedEvent { teamId: string - lastSyncTimestamp: number + lastSyncSeq: number } diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index 1ed66a75cd..61ec63354a 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -40,7 +40,7 @@ import { } from './community' import { ErrorPayload } from './errors' import { HCaptchaChallengeRequest, HCaptchaFormResponse, HCaptchaRequest } from './captcha' -import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, NseSyncTimestampUpdatedEvent } from './keys' +import { DeviceCredentialsUpdatedEvent, KeysUpdatedEvent, NseQssUrlUpdatedEvent, NseSyncSeqUpdatedEvent } from './keys' // ----------------------------------------------------------------------------- // SocketActions: These are the actions the frontend emits to the backend @@ -153,7 +153,7 @@ export enum SocketEvents { QSS_CONNECTED = 'qssConnected', QSS_DISCONNECTED = 'qssDisconnected', NSE_QSS_URL_UPDATED = 'nseQssUrlUpdated', - NSE_SYNC_TIMESTAMP_UPDATED = 'nseSyncTimestampUpdated', + NSE_SYNC_SEQ_UPDATED = 'nseSyncSeqUpdated', MIGRATION_DATA_REQUIRED = 'migrationDataRequired', PUSH_NOTIFICATION = 'pushNotification', CONNECTION_PROCESS_INFO = 'connectionProcess', @@ -265,7 +265,7 @@ export interface SocketEventsMap { [SocketEvents.QSS_CONNECTED]: EmitEvent [SocketEvents.QSS_DISCONNECTED]: EmitEvent [SocketEvents.NSE_QSS_URL_UPDATED]: EmitEvent - [SocketEvents.NSE_SYNC_TIMESTAMP_UPDATED]: EmitEvent + [SocketEvents.NSE_SYNC_SEQ_UPDATED]: EmitEvent [SocketEvents.MIGRATION_DATA_REQUIRED]: EmitEvent [SocketEvents.PUSH_NOTIFICATION]: EmitEvent [SocketEvents.CONNECTION_PROCESS_INFO]: EmitEvent From efe168b3e60a462946c6b37d08b33084eb798cb6 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Apr 2026 17:15:00 -0400 Subject: [PATCH 37/92] disconnect more gracefully --- packages/backend/src/nest/qss/qss-auth-conn.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/nest/qss/qss-auth-conn.ts b/packages/backend/src/nest/qss/qss-auth-conn.ts index ccd913670a..17d5ef71fe 100644 --- a/packages/backend/src/nest/qss/qss-auth-conn.ts +++ b/packages/backend/src/nest/qss/qss-auth-conn.ts @@ -57,12 +57,14 @@ export class QSSAuthConnection extends EventEmitter { this._setupEventHandlers() } + private _onQssDisconnected = (): void => { + this.logger.warn('QSS disconnected, closing auth connection', this.teamId) + this.stop(false) + this._authConnection = undefined + } + private _setupEventHandlers(): void { - this.qssClient.on(QSSEvents.QSS_DISCONNECTED, () => { - this.logger.warn('QSS disconnected, closing auth connection', this.teamId) - this.stop(true) - this._authConnection = undefined - }) + this.qssClient.on(QSSEvents.QSS_DISCONNECTED, this._onQssDisconnected) } public get teamId(): string | undefined { @@ -275,6 +277,8 @@ export class QSSAuthConnection extends EventEmitter { * @param sendDisconnectToQSS If true send a disconnect message to QSS on closure */ public stop(sendDisconnectToQSS = false): void { + this.qssClient.off(QSSEvents.QSS_DISCONNECTED, this._onQssDisconnected) + if (this._authConnection == null) { this.logger.warn(`Auth connection not open with QSS for this team`, this.teamId) return From 633528741f19f8f98cc6c64f0d446a8109dddfb6 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Apr 2026 18:10:05 -0400 Subject: [PATCH 38/92] fix socket lifecycle listeners --- packages/backend/src/nest/qss/qss.service.ts | 5 ++-- .../startConnection/startConnection.saga.ts | 10 +++++-- .../startConnection/startConnection.saga.ts | 30 +++++++++++++++++-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index e0588aed0f..8ab37f1b8c 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -602,14 +602,15 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul } public startLogPullInterval(teamId: string): void { - this.logger.debug('Starting log pull interval', teamId) if (this._logPullIntervals.has(teamId)) { + this.logger.debug('Log pull interval already exists, skipping', teamId) return } + this.logger.debug('Starting log pull interval', teamId) const interval = setInterval(() => { void this._pullLatestLogEntriesForTeam(teamId) - }, 30_000) + }, 1_000) this._logPullIntervals.set(teamId, interval) void this._pullLatestLogEntriesForTeam(teamId) diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 1da585edcf..941cae33b7 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -77,9 +77,13 @@ function* setConnectedSaga(socket: Socket): Generator { function* handleSocketLifecycleActions(socket: Socket, socketIOData: WebsocketConnectionPayload): Generator { const socketChannel = yield* call(subscribeSocketLifecycle, socket, socketIOData) - yield takeEvery(socketChannel, function* (action) { - yield put(action) - }) + try { + yield takeEvery(socketChannel, function* (action) { + yield put(action) + }) + } finally { + socketChannel.close() + } } function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnectionPayload) { diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index ef3ebff65e..01e6dec93e 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -218,20 +218,46 @@ export function subscribe(socket: Socket) { emit(captchaActions.setCaptchaVerified(payload)) }) - return () => undefined + return () => { + socket.off(SocketEvents.COMMUNITY_LAUNCHED) + socket.off(SocketEvents.TOR_INITIALIZED) + socket.off(SocketEvents.QSS_CONNECTED) + socket.off(SocketEvents.QSS_DISCONNECTED) + socket.off(SocketEvents.CONNECTION_PROCESS_INFO) + socket.off(SocketEvents.PEER_CONNECTED) + socket.off(SocketEvents.PEER_DISCONNECTED) + socket.off(SocketEvents.MIGRATION_DATA_REQUIRED) + socket.off(SocketEvents.MESSAGE_MEDIA_UPDATED) + socket.off(SocketEvents.FILE_ATTACHED) + socket.off(SocketEvents.DOWNLOAD_PROGRESS) + socket.off(SocketEvents.REMOVE_DOWNLOAD_STATUS) + socket.off(SocketEvents.CHANNELS_STORED) + socket.off(SocketEvents.CHANNEL_SUBSCRIBED) + socket.off(SocketEvents.MESSAGE_IDS_STORED) + socket.off(SocketEvents.MESSAGES_STORED) + socket.off(SocketEvents.CREATED_LONG_LIVED_LFA_INVITE) + socket.off(SocketEvents.ERROR) + socket.off(SocketEvents.USERS_UPDATED) + socket.off(SocketEvents.USERS_REMOVED) + socket.off(SocketEvents.USER_PROFILES_STORED) + socket.off(SocketEvents.HCAPTCHA_CHALLENGE_REQUEST) + socket.off(SocketEvents.HCAPTCHA_SITE_KEY) + socket.off(SocketEvents.HCAPTCHA_VERIFICATION_UPDATE) + } }) } export function* handleActions(socket: Socket): Generator { logger.info('handleActions starting') + const socketChannel = yield* call(subscribe, socket) try { - const socketChannel = yield* call(subscribe, socket) yield takeEvery(socketChannel, function* (action) { logger.info('Dispatching action', action.type) yield put(action) }) } finally { logger.info('handleActions stopping') + socketChannel.close() if (yield cancelled()) { logger.info('handleActions cancelled') } From 41112698ea2416cf548e759523988548ff6c2bf9 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 1 Apr 2026 18:14:36 -0400 Subject: [PATCH 39/92] rm noisy debugs --- .../src/nest/auth/services/crypto/crypto.service.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts index 495aeb61ef..f5721a73da 100644 --- a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -135,12 +135,12 @@ class CryptoService extends ChainServiceBase { ): DecryptedPayload { let contents: T if (typeof encrypted.contents === 'string') { - logger.debug('Converting Base64 string to Buffer') + // logger.debug('Converting Base64 string to Buffer') encrypted.contents = Buffer.from(encrypted.contents, 'base64') } // Handle numeric array case (JSON-encoded Uint8Array) else if (Array.isArray(encrypted.contents)) { - logger.debug('Converting numeric array to Buffer') + // logger.debug('Converting numeric array to Buffer') encrypted.contents = Buffer.from(encrypted.contents) } // Handle Node.js Buffer JSON representation ({"type":"Buffer","data":[...]}) @@ -150,12 +150,12 @@ class CryptoService extends ChainServiceBase { (encrypted.contents as any).type === 'Buffer' && Array.isArray((encrypted.contents as any).data) ) { - logger.debug('Converting JSON Buffer representation to Buffer') + // logger.debug('Converting JSON Buffer representation to Buffer') encrypted.contents = Buffer.from((encrypted.contents as any).data) } // Handle object with numeric keys (parsed JSON representation) else if (encrypted.contents && typeof encrypted.contents === 'object' && !Buffer.isBuffer(encrypted.contents)) { - logger.debug('Converting object with numeric keys to Buffer') + // logger.debug('Converting object with numeric keys to Buffer') const nums = Object.keys(encrypted.contents) .filter(key => /^\d+$/.test(key)) .map(key => (encrypted.contents as any)[key] as number) From f17ea88c7bed7a7960edb9a20fb4b40672b5613e Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 13:31:29 -0400 Subject: [PATCH 40/92] cleanup dead code --- .../NSECryptoService.swift | 31 +++++-------------- .../NSEKeychainHelper.swift | 11 ------- 2 files changed, 7 insertions(+), 35 deletions(-) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift index e2dfbae959..443f5dbe3c 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -25,10 +25,6 @@ private struct NSEEncryptedPayload { // MARK: - Protocol protocol DeviceCryptography { - /// Sign message bytes with the device Ed25519 private key. - /// Returns raw 64-byte signature. - func sign(message: Data) throws -> Data - /// Signs a challenge payload exactly as `identity.prove()` does in TypeScript. func signChallengePayload(_ challenge: ChallengePayload, privateKeyData: Data) throws -> ProofPayload @@ -51,15 +47,6 @@ extension DeviceCryptography { publicKey: Base58.encode(publicKeyBytes) ) } - - func signBytes(_ message: Data, privateKeyData: Data) throws -> Data { - guard privateKeyData.count == 64 || privateKeyData.count == 32 else { - throw NSECryptoError.invalidKeyLength(expected: 64, got: privateKeyData.count) - } - let seed = privateKeyData.prefix(32) - let privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: seed) - return try privateKey.signature(for: message) - } } // MARK: - Errors @@ -70,7 +57,6 @@ enum NSECryptoError: Error, LocalizedError { case invalidPayload(String) case msgpack(String) case decryptionFailed(String) - case signingFailed var errorDescription: String? { switch self { @@ -79,7 +65,6 @@ enum NSECryptoError: Error, LocalizedError { case .invalidPayload(let msg): return "Invalid payload: \(msg)" case .msgpack(let msg): return "MessagePack decoding failed: \(msg)" case .decryptionFailed(let msg): return "Decryption failed: \(msg)" - case .signingFailed: return "Signing failed" } } } @@ -99,23 +84,21 @@ class NSECryptoService: DeviceCryptography { return [UInt8](salt) }() - /// Signs raw bytes using the device Ed25519 private key. - /// The 64-byte libsodium secret key = 32-byte seed ++ 32-byte public key. - /// CryptoKit only needs the 32-byte seed. - func sign(message: Data) throws -> Data { - throw NSECryptoError.signingFailed - } - func decryptNotificationMessage(from logEntry: LogEntry, teamId: String) throws -> NSEDecryptedNotificationMessage? { let outerEnvelope = try self.decodeObject(logEntry.entry) guard let outerDict = outerEnvelope as? NSEJSONObject else { - return nil + throw NSECryptoError.invalidPayload("outer envelope was not an object") } let outerEncrypted = try self.parseEncryptedPayload(outerDict["encrypted"], label: "outer QSS payload") let orbitEntry = try self.decryptPayload(outerEncrypted, teamId: teamId) guard - let orbitEntryDict = orbitEntry as? NSEJSONObject, + let orbitEntryDict = orbitEntry as? NSEJSONObject + else { + throw NSECryptoError.invalidPayload("decrypted OrbitDB entry was not an object") + } + + guard let payload = orbitEntryDict["payload"] as? NSEJSONObject, let payloadValue = payload["value"] as? NSEJSONObject else { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index 89adf29508..88a24800a4 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -8,7 +8,6 @@ struct NSEKeychainHelper { private static let devicePrivateKeyPrefix = "quiet.device.privateKey." private static let deviceIdKey = "quiet.device.id" - private static let teamIdKey = "quiet.team.id" private static let lastSyncSeqKey = "quiet.nse.lastSyncSeq" private static let qssUrlsKey = "quiet.nse.qssUrls" private static let appIsForegroundKey = "quiet.app.isForeground" @@ -37,16 +36,6 @@ struct NSEKeychainHelper { return str } - // MARK: - Team ID - - static func getTeamId() throws -> String { - let data = try readData(account: teamIdKey, label: "team ID") - guard let str = String(data: data, encoding: .utf8) else { - throw NSEAuthError.keychainError("team ID is not valid UTF-8") - } - return str - } - static func getLfaKeyString(keyName: String) throws -> String { let data = try readData(account: keyName, label: "LFA key", service: lfaKeyService) guard let str = String(data: data, encoding: .utf8) else { From b48bf7b74e772d0d8a888a656190ac8e97f7ea1a Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 14:49:00 -0400 Subject: [PATCH 41/92] refactor: replace hardcoded userDefaultsSuite string with constant --- .../NSECryptoService.swift | 49 +++++++------------ .../NSEKeychainHelper.swift | 9 ++-- 2 files changed, 23 insertions(+), 35 deletions(-) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift index 443f5dbe3c..35c4536c0c 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -333,23 +333,17 @@ enum NSEMsgpack { /// Encodes a ChallengePayload in the same byte format as msgpackr.pack(). static func encode(_ challenge: ChallengePayload) throws -> Data { var out = Data() - // map16 with 4 elements: 0xde 0x00 0x04 // msgpackr always uses map16, never fixmap, regardless of element count. out.append(0xde) out.append(0x00) out.append(0x04) - // key: "type" value: challenge.type try appendString("type", to: &out) try appendString(challenge.type, to: &out) - // key: "name" value: challenge.name try appendString("name", to: &out) try appendString(challenge.name, to: &out) - // key: "nonce" value: challenge.nonce try appendString("nonce", to: &out) try appendString(challenge.nonce, to: &out) - // key: "timestamp" value: challenge.timestamp - // Date.now() returns ms since epoch (~1.7e12), which exceeds 2^32. - // msgpackr encodes values > 2^32 as float64, not uint64. + // Date.now() ms since epoch (~1.7e12) exceeds 2^32; msgpackr encodes as float64, not uint64. try appendString("timestamp", to: &out) appendFloat64(Double(challenge.timestamp), to: &out) return out @@ -545,6 +539,21 @@ enum NSEMsgpack { return try self.readData(length: length) } + private func readRecordDefinition(recordId: UInt8) throws -> Any { + let keysValue = try self.readValue() + guard let keys = keysValue as? [Any] else { + throw MsgpackError.invalidRecordDefinition + } + let stringKeys = try keys.map { key -> String in + guard let key = key as? String else { + throw MsgpackError.invalidRecordDefinition + } + return key + } + self.records[recordId] = stringKeys + return try self.readRecord(stringKeys) + } + private func readExtension(length: Int) throws -> Any { let type = try self.readByte() let payload = try self.readData(length: length) @@ -552,18 +561,7 @@ enum NSEMsgpack { guard let recordId = payload.first else { throw MsgpackError.invalidRecordDefinition } - let keysValue = try self.readValue() - guard let keys = keysValue as? [Any] else { - throw MsgpackError.invalidRecordDefinition - } - let stringKeys = try keys.map { key -> String in - guard let key = key as? String else { - throw MsgpackError.invalidRecordDefinition - } - return key - } - self.records[recordId] = stringKeys - return try self.readRecord(stringKeys) + return try self.readRecordDefinition(recordId: recordId) } return payload } @@ -575,18 +573,7 @@ enum NSEMsgpack { guard let recordId = payload.first else { throw MsgpackError.invalidRecordDefinition } - let keysValue = try self.readValue() - guard let keys = keysValue as? [Any] else { - throw MsgpackError.invalidRecordDefinition - } - let stringKeys = try keys.map { key -> String in - guard let key = key as? String else { - throw MsgpackError.invalidRecordDefinition - } - return key - } - self.records[recordId] = stringKeys - return try self.readRecord(stringKeys) + return try self.readRecordDefinition(recordId: recordId) } // msgpackr uses fixext1 type 0 data 0 for undefined. Treat it as nil-like. if type == 0x00, length == 1, payload.first == 0x00 { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index 88a24800a4..cff0c0f6f9 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -12,6 +12,7 @@ struct NSEKeychainHelper { private static let qssUrlsKey = "quiet.nse.qssUrls" private static let appIsForegroundKey = "quiet.app.isForeground" private static let lfaKeyService = "com.quietmobile" + private static let userDefaultsSuite = "group.com.quietmobile" // MARK: - Device private key @@ -47,19 +48,19 @@ struct NSEKeychainHelper { // MARK: - Last sync state (UserDefaults — not sensitive) static func getLastSyncSeq() -> Int64 { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard return Int64(defaults.double(forKey: lastSyncSeqKey)) } static func saveLastSyncSeq(_ seq: Int64) { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard let current = Int64(defaults.double(forKey: lastSyncSeqKey)) os_log("Saving last sync seq: current=%{public}lld, new=%{public}lld", current, seq) defaults.set(Double(max(current, seq)), forKey: lastSyncSeqKey) } static func getQssUrl(teamId: String) -> URL? { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard guard let qssUrls = defaults.dictionary(forKey: qssUrlsKey) as? [String: String], let qssUrlString = qssUrls[teamId] @@ -71,7 +72,7 @@ struct NSEKeychainHelper { } static func isMainAppForeground() -> Bool { - let defaults = UserDefaults(suiteName: "group.com.quietmobile") ?? UserDefaults.standard + let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard return defaults.bool(forKey: appIsForegroundKey) } From be4b988cf4477610e8337ca8ba3c7217c120f36c Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 18:33:57 -0400 Subject: [PATCH 42/92] minor pr cleaning --- CLAUDE_WORKLOG.md | 210 ------------------ .../backend/src/nest/auth/sigchain.service.ts | 2 +- packages/mobile/.env.development | 2 +- packages/mobile/ios/CommunicationModule.swift | 9 +- .../NSEKeychainHelper.swift | 6 +- 5 files changed, 11 insertions(+), 218 deletions(-) delete mode 100644 CLAUDE_WORKLOG.md diff --git a/CLAUDE_WORKLOG.md b/CLAUDE_WORKLOG.md deleted file mode 100644 index 5aa70c6b1d..0000000000 --- a/CLAUDE_WORKLOG.md +++ /dev/null @@ -1,210 +0,0 @@ -You are continuing implementation of iOS push notifications for the - Quiet app (branch: fix/notification-mvp-tweaks in - /Users/taea/dev/quiet). - - ## What this feature does - When a Quiet community member sends a message, FCM fires a push - notification to all iOS devices. The iOS Notification Service - Extension (NSE) intercepts it, authenticates with QSS, fetches new - OrbitDB log entries, and increments the badge count — all before the - notification is displayed. - - ## Session 0 - - ### QSS backend (3rd-party/qss/) - - NEW: `app/src/nest/nse-auth/` module with three REST endpoints: - - `POST /nse-auth/challenge` — issues LFA-style challenge - `{type:'DEVICE', name:deviceId, nonce, timestamp}` - - `POST /nse-auth/token` — verifies Ed25519 signature (libsodium + - msgpackr) and returns 15-min JWT - - `GET /nse-auth/logs/:teamId?since=` — JWT-guarded; returns - log entries with Buffer serialized as `{type:'Buffer',data:[...]}` - - TODO: Add `NSE_JWT_SECRET` env var to `.env.local.docker` and prod - env - - ### iOS NSE (packages/mobile/ios/QuietNotificationServiceExtension/) - - `NotificationService.swift` — full fetch/auth/badge flow (reads - teamId+qssUrl from APNs payload) - - `NSEAuthService.swift`, `NSENetworkClient.swift`, - `NSECryptoService.swift`, `NSEKeychainHelper.swift`, - `NSEModels.swift` — complete auth+fetch stack - - `NSECryptoService` signs with CryptoKit Ed25519; `ProofPayload` - now includes both `signature` and `publicKey` - - `NSEKeychainHelper.getDevicePrivateKey` Base58-decodes the stored - LFA key before returning raw bytes - - All three entitlements files now declare `group.com.quietmobile` - in `com.apple.security.application-groups` - - ### Device credentials pipeline (new end-to-end) - - `@quiet/types`: `DeviceCredentialsUpdatedEvent` type + - `SocketEvents.DEVICE_CREDENTIALS_UPDATED` - - `sigchain.service.ts`: emits deviceId, teamId, signing private key - on iOS on every chain update - - Mobile state-manager: new `saveDeviceCredentials` action/saga - wired to the socket event - - `CommunicationModule.swift`: - `saveDeviceCredentials(_:teamId:signingPrivateKey:)` writes to - Keychain with `group.com.quietmobile` + - `kSecAttrAccessibleAfterFirstUnlock` - - `CommunicationBridge.m`: ObjC bridge registered - - ### FCM payload fix (packages/backend/) - - `qps.service.ts` `sendBatchPush` now injects `teamId` and `qssUrl` - into the FCM data payload so the NSE guard clauses pass - - ### LAN config (3rd-party/qss/app/) - - `docker-compose.quiet.yml`: bridge binding changed from - `127.0.0.1` → `0.0.0.0` - - `.env.local.docker`: `QSS_HOSTNAME=192.168.1.175` (update if IP - changes) - - Quiet backend needs `QSS_ENDPOINT=http://192.168.1.175:3003` in - its env at launch - - ## Known remaining gaps - - 1. **Device registration with QSS NSE auth** — the - `/nse-auth/challenge` endpoint currently issues a challenge to any - deviceId without verifying the device is actually registered. - There's a TODO comment in `nse-auth.service.ts` to add UCAN-level - trust anchor verification (check that the public key in the proof - belongs to a UCAN registered for that device+team). - - 2. **`NSEKeychainHelper.lastSyncTimestamp` uses - `UserDefaults.standard`** — this is NOT shared with the main app. If - you want to seed an initial timestamp (to avoid fetching all - history on first run), the main app should write it using - `UserDefaults(suiteName: "group.com.quietmobile")` and the NSE - should read from the same suite. - - 3. **`UserDefaults` for lastSyncTimestamp not in app group** — - `NSEKeychainHelper` uses `UserDefaults.standard` which is - process-local. Update both writer (if any) and reader to use - `UserDefaults(suiteName: "group.com.quietmobile")`. - - 4. **Rebuild needed** — after the `qps.service.ts` and - `sigchain.service.ts` changes, rebuild the backend bundle: `npx - lerna run prepare --scope @quiet/backend` - - 5. **QSS needs `NSE_JWT_SECRET`** — add to `.env.local.docker` for - stable JWT signing across restarts. - - 6. **Test flow** — once rebuilt and redeployed: - - Confirm `quiet.device.id`, `quiet.device.privateKey.*`, - `quiet.team.id` appear in Keychain (check Console.app for - `saveDeviceCredentials: stored` logs) - - Send a message; check Console.app filtered to - `com.quietmobile.QuietNotificationServiceExtension` - - Look for `fetchAndUpdate: fetching entries since=`, auth logs, - and badge update - - ## Architecture reference - - NSE files: - `packages/mobile/ios/QuietNotificationServiceExtension/` - - Main app bridge: `packages/mobile/ios/CommunicationModule.swift` + - `CommunicationBridge.m` - - Backend QPS: `packages/backend/src/nest/qps/qps.service.ts` - - QSS NSE auth: `3rd-party/qss/app/src/nest/nse-auth/` - - Types: `packages/types/src/keys.ts`, - `packages/types/src/socket.ts` - - Mobile sagas: `packages/mobile/src/store/keys/` - - Continue from here. - -## Session 1 - -### Fixed: UserDefaults → shared app group suite -- `NSEKeychainHelper.getLastSyncTimestamp` and `saveLastSyncTimestamp` now use - `UserDefaults(suiteName: "group.com.quietmobile")` instead of `UserDefaults.standard`. -- This allows the main app to seed an initial timestamp, and the NSE to read - the same value — both run in the same App Group. - -### Fixed: NSE_JWT_SECRET added to .env.local.docker -- Added `NSE_JWT_SECRET=change-me-in-production` to - `3rd-party/qss/app/.env.local.docker`. -- Without this, QSS generates a random per-process fallback secret so tokens - become invalid across restarts. - -## Session 2 - -### Fixed: NSEMsgpack encoding mismatch (critical — signatures always failed) - -`msgpackr.pack()` uses two non-obvious encodings that the hand-rolled Swift -encoder was getting wrong, causing signature verification to always fail: - -1. **Map header**: msgpackr uses `map16` (`0xde 0x00 0x04`) for ALL object - sizes, never `fixmap`. The Swift encoder was emitting `0x84` (fixmap). - -2. **Timestamp encoding**: JavaScript's `Date.now()` returns ~1.7e12, which - exceeds 2^32. msgpackr encodes values > 2^32 as `float64` (`0xcb` + 8-byte - IEEE 754), not `uint64`. The Swift encoder was emitting `uint64` (`0xcf`). - -Verification (msgpackr output): -``` -de0004 a474797065 a644455649... a974696d657374616d70 cb4278e5f8c1600000 -^^^ ^^ float64, not 0xcf -map16 -``` - -Fix: updated `NSEMsgpack.encode()` in `NSECryptoService.swift` to emit -`map16` header and `appendFloat64()` instead of `appendUInt64()` for the -timestamp field. - -## Session 3 - -### Fixed: kSecAttrAccessible removed from Keychain read query -`NSEKeychainHelper.readData` was including `kSecAttrAccessible` in -`SecItemCopyMatching`. Apple's docs list it as a write attribute only; -including it in a search query can silently prevent matches on some iOS -versions. Removed from all read queries (accessibility is enforced at -write time in `CommunicationModule.swift`). - -### Fixed: qssUrl scheme mismatch (ws:// → http://) -`qps.service.ts` was sending `qssEndpoint` directly in the FCM data -payload. `qssEndpoint` is a WebSocket URL (`ws://host:port`). The NSE -uses this URL for HTTP REST calls via `URLSession.data(for:)`, which -does not support the `ws://` scheme and would throw -"unsupported URL scheme". Fix: replace `ws://` → `http://` and -`wss://` → `https://` before putting the URL in the FCM payload. - -## Session 4 - -### Fixed: ISO8601DateFormatter missing .withFractionalSeconds -`NotificationService.swift` used `ISO8601DateFormatter()` with default -options to parse `receivedAt` timestamps from QSS log entries. Luxon's -`DateTime.toISO()` always includes milliseconds (`"...T10:00:00.000Z"`), -which the default formatter cannot parse — `compactMap` would return `[]` -for all entries, making `newTs` nil and triggering the "advancing sync -pointer by 1ms" fallback. Badge would always be wrong. -Fixed by configuring formatter with `[.withInternetDateTime, .withFractionalSeconds]`. - -### Verified clean: end-to-end flow audit -Full trace reviewed — no additional blocking issues found: -- `communityId` in QSS log entries = `sigchain.team.id` = `teamId` in FCM payload ✓ -- `LogEntriesResponse { entries: [...] }` matches Swift `LogEntriesResponse.entries` decoder ✓ -- `entry` NodeBuffer `{type:"Buffer",data:[...]}` decodes correctly to `Data` in Swift ✓ -- `kSecAttrAccessGroup: "group.com.quietmobile"` with App Group entitlement is valid - for cross-process Keychain sharing (NSE + main app, no keychain-access-groups needed) ✓ -- `KeychainHelper.swift` in NSE folder is unrelated dead code; NSE uses `NSEKeychainHelper` ✓ -- `process.platform === 'ios'` in nodejs-mobile confirmed by existing codebase patterns ✓ - -## Feature status: READY FOR TESTING - -All known blocking code bugs fixed across 4 sessions. Remaining items -require user action: - -1. **Backend rebuild** — `npx lerna run prepare --scope @quiet/backend` -2. **QSS redeploy** — `docker compose -f docker-compose.quiet.yml up -d` in `3rd-party/qss/app/` -3. **Test on device**: - - Launch app → join community → watch Console.app for `saveDeviceCredentials: stored` - - From another device, send a message - - Watch NSE Console.app for `fetchAndUpdate: teamId=`, `authenticate:`, `fetched X entries`, badge update -4. **UCAN trust** (post-MVP) — `/nse-auth/challenge` should verify the device public key - is registered in the @localfirst/auth sigchain for the teamId (TODO comment in `nse-auth.service.ts`) - -## Session 5 - -### Loop terminating — feature complete - -No remaining blocking code bugs. All 4 sessions of fixes are committed. Remaining items -are all user-action (rebuild, redeploy, device test) or post-MVP (UCAN trust). -Recurring cron job f1f8b51a deleted. diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 54cc3cfb3e..971cdca7de 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -143,7 +143,7 @@ export class SigChainService extends EventEmitter { private handleChainUpdate = (teamName: string) => { this._updateUsersOnChainUpdate(teamName) - this._updateKeysOnChainUpdate(teamName) + void this._updateKeysOnChainUpdate(teamName) this._updateDeviceCredentials(teamName) this.emit('updated', teamName) this.saveChain(teamName) diff --git a/packages/mobile/.env.development b/packages/mobile/.env.development index 98cc3b88dd..40ea712d18 100644 --- a/packages/mobile/.env.development +++ b/packages/mobile/.env.development @@ -3,5 +3,5 @@ NODE_ENV=development SHOULD_RUN_BACKEND_WORKER=true COLORIZE=false QSS_ALLOWED=true -QSS_ENDPOINT=ws://192.168.1.175:3003 +QSS_ENDPOINT=ws://127.0.0.1:3003 QPS_ALLOWED=true diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 6ae53c6a24..768cef4ecb 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -90,7 +90,10 @@ class CommunicationModule: RCTEventEmitter { let decoder = JSONDecoder() for keyAsAny in newKeys { do { - let keyAsString: String = keyAsAny as! String + guard let keyAsString = keyAsAny as? String else { + CommunicationModule.logger.error("saveKeysInKeychain: unexpected non-string element in keys array") + continue + } let data = Data(keyAsString.utf8) let decodedNamedKey = try decoder.decode(NamedKey.self, from: data) _ = try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey) @@ -192,8 +195,8 @@ class CommunicationModule: RCTEventEmitter { syncSeq: NSNumber ) { let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - let newSyncSeq = syncSeq.doubleValue - let existingSyncSeq = defaults.double(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) + let newSyncSeq = syncSeq.intValue + let existingSyncSeq = defaults.integer(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) let teamIdStr = teamId as String if existingSyncSeq >= newSyncSeq { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index cff0c0f6f9..a7a69c286c 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -49,14 +49,14 @@ struct NSEKeychainHelper { static func getLastSyncSeq() -> Int64 { let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - return Int64(defaults.double(forKey: lastSyncSeqKey)) + return Int64(defaults.integer(forKey: lastSyncSeqKey)) } static func saveLastSyncSeq(_ seq: Int64) { let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - let current = Int64(defaults.double(forKey: lastSyncSeqKey)) + let current = Int64(defaults.integer(forKey: lastSyncSeqKey)) os_log("Saving last sync seq: current=%{public}lld, new=%{public}lld", current, seq) - defaults.set(Double(max(current, seq)), forKey: lastSyncSeqKey) + defaults.set(Int(max(current, seq)), forKey: lastSyncSeqKey) } static func getQssUrl(teamId: String) -> URL? { From 0ea4782f97927f7b8fee94856f6d8032545051b4 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 18:38:15 -0400 Subject: [PATCH 43/92] more cleanup --- .../backend/src/nest/auth/services/crypto/crypto.service.ts | 4 ---- packages/desktop/.env.development | 1 - 2 files changed, 5 deletions(-) diff --git a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts index f5721a73da..533cee2340 100644 --- a/packages/backend/src/nest/auth/services/crypto/crypto.service.ts +++ b/packages/backend/src/nest/auth/services/crypto/crypto.service.ts @@ -135,12 +135,10 @@ class CryptoService extends ChainServiceBase { ): DecryptedPayload { let contents: T if (typeof encrypted.contents === 'string') { - // logger.debug('Converting Base64 string to Buffer') encrypted.contents = Buffer.from(encrypted.contents, 'base64') } // Handle numeric array case (JSON-encoded Uint8Array) else if (Array.isArray(encrypted.contents)) { - // logger.debug('Converting numeric array to Buffer') encrypted.contents = Buffer.from(encrypted.contents) } // Handle Node.js Buffer JSON representation ({"type":"Buffer","data":[...]}) @@ -150,12 +148,10 @@ class CryptoService extends ChainServiceBase { (encrypted.contents as any).type === 'Buffer' && Array.isArray((encrypted.contents as any).data) ) { - // logger.debug('Converting JSON Buffer representation to Buffer') encrypted.contents = Buffer.from((encrypted.contents as any).data) } // Handle object with numeric keys (parsed JSON representation) else if (encrypted.contents && typeof encrypted.contents === 'object' && !Buffer.isBuffer(encrypted.contents)) { - // logger.debug('Converting object with numeric keys to Buffer') const nums = Object.keys(encrypted.contents) .filter(key => /^\d+$/.test(key)) .map(key => (encrypted.contents as any)[key] as number) diff --git a/packages/desktop/.env.development b/packages/desktop/.env.development index 31c1255c0f..69254342b9 100644 --- a/packages/desktop/.env.development +++ b/packages/desktop/.env.development @@ -2,7 +2,6 @@ COLORIZE=true QSS_ALLOWED=true QPS_ALLOWED=true -QPS_ENABLED=true QSS_ENDPOINT=ws://127.0.0.1:3003 LOG_TO_FILE=true ENVFILE=.env.development From 3b685c979b472136487e8624a3f26b386dc164ad Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 18:50:39 -0400 Subject: [PATCH 44/92] test fixes --- packages/backend/src/nest/qss/qss.service.ts | 8 ++++---- .../storage/userProfile/userProfile.store.spec.ts | 4 ++-- packages/mobile/ios/KeychainHandler.swift | 12 ++---------- .../NotificationService.swift | 7 ------- packages/state-manager/package-lock.json | 14 +++++++------- packages/state-manager/package.json | 2 +- 6 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 56e76697b0..4fe6437aca 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -645,13 +645,13 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.startLogPullInterval(teamId) } - authConnection?.removeAllListeners(QSSEvents.QSS_AUTH_CONNECTED) - authConnection?.removeAllListeners(QSSEvents.QSS_DISCONNECTED) - authConnection?.on(QSSEvents.QSS_AUTH_CONNECTED, () => { + authConnection?.removeAllListeners?.(QSSEvents.QSS_AUTH_CONNECTED) + authConnection?.removeAllListeners?.(QSSEvents.QSS_DISCONNECTED) + authConnection?.on?.(QSSEvents.QSS_AUTH_CONNECTED, () => { this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_CONNECTED) startLogPullInterval() }) - authConnection?.on(QSSEvents.QSS_DISCONNECTED, () => { + authConnection?.on?.(QSSEvents.QSS_DISCONNECTED, () => { this.logger.info('Disconnected event received, stopping log entry pull interval', teamId) this.socketService.serverIoProvider.io.emit(SocketEvents.QSS_DISCONNECTED) this._stopLogPullInterval(teamId) diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts index b3cbf30a47..46b87f2441 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts @@ -97,7 +97,7 @@ describe('UserProfileStore', () => { const entry = await userProfileStore.setEntry(userProfile.userId, userProfile) expect(entry).toBeDefined() const result = await userProfileStore.getEntry(userProfile.userId) - expect(result).toEqual(userProfile) + expect(result).toEqual(UserProfileStore.sanitizeUserProfile(userProfile)) }) test('should get all user profiles', async () => { @@ -105,7 +105,7 @@ describe('UserProfileStore', () => { expect(entry).toBeDefined() const result = await userProfileStore.getUserProfiles() expect(result).toHaveLength(1) - expect(result[0]).toEqual(userProfile) + expect(result[0]).toEqual(UserProfileStore.sanitizeUserProfile(userProfile)) }) test('should cache userId to nickname mapping', async () => { diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index 789b5574ea..f350fb8d28 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -1,14 +1,6 @@ -// -// KeychainError.swift -// Quiet -// -// Created by Isla Koenigsknecht on 2/25/26. -// - import Foundation import CryptoKit import Security -import CoreData import OSLog public enum KeychainError: Error { @@ -43,7 +35,7 @@ public struct NamedKey: Codable { class KeychainHandler: NSObject { private let keychainService: String = "com.quietmobile" private lazy var accessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String - + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler") public func getLfaKeyString(keyName: String) throws -> String { @@ -173,7 +165,7 @@ class KeychainHandler: NSObject { } do { - let addStatus = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true) + _ = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true) } catch { KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)") } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 94f911895d..785d016490 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -1,10 +1,3 @@ -// -// NotificationService.swift -// QuietNotificationServiceExtension -// -// Created by Taea Vogel on 3/12/26. -// - import UserNotifications import os.log diff --git a/packages/state-manager/package-lock.json b/packages/state-manager/package-lock.json index ef751fbf95..985c5c0c20 100644 --- a/packages/state-manager/package-lock.json +++ b/packages/state-manager/package-lock.json @@ -14,7 +14,7 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", - "lodash": "^4.17.23", + "lodash": "4.17.21", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", @@ -11347,9 +11347,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, "node_modules/lodash.isequal": { @@ -21445,9 +21445,9 @@ } }, "lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.isequal": { "version": "4.5.0", diff --git a/packages/state-manager/package.json b/packages/state-manager/package.json index d2f8580f05..7f8437663b 100644 --- a/packages/state-manager/package.json +++ b/packages/state-manager/package.json @@ -33,7 +33,7 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", - "lodash": "^4.17.23", + "lodash": "4.17.21", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", From 7916588296bbf61b8a3bbaa906cc0a4dae67f3b4 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 2 Apr 2026 19:20:53 -0400 Subject: [PATCH 45/92] fix lodash dependency --- packages/state-manager/package-lock.json | 19 +++---------------- packages/state-manager/package.json | 2 -- .../userProfile/updateUserProfiles.saga.ts | 3 +-- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/state-manager/package-lock.json b/packages/state-manager/package-lock.json index 985c5c0c20..dee06f1552 100644 --- a/packages/state-manager/package-lock.json +++ b/packages/state-manager/package-lock.json @@ -14,7 +14,6 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", - "lodash": "4.17.21", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", @@ -31,7 +30,6 @@ "@babel/preset-typescript": "^7.22.5", "@types/factory-girl": "^5.0.12", "@types/jest": "^26.0.24", - "@types/lodash": "^4.17.24", "@types/luxon": "^2.0.0", "@types/redux-saga": "^0.10.5", "babel-jest": "^29.3.1", @@ -4884,13 +4882,6 @@ "pretty-format": "^26.0.0" } }, - "node_modules/@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/luxon": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz", @@ -11350,6 +11341,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.isequal": { @@ -16502,12 +16494,6 @@ "pretty-format": "^26.0.0" } }, - "@types/lodash": { - "version": "4.17.24", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", - "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", - "dev": true - }, "@types/luxon": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.0.tgz", @@ -21447,7 +21433,8 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash.isequal": { "version": "4.5.0", diff --git a/packages/state-manager/package.json b/packages/state-manager/package.json index 7f8437663b..1e33daa729 100644 --- a/packages/state-manager/package.json +++ b/packages/state-manager/package.json @@ -33,7 +33,6 @@ "@reduxjs/toolkit": "^1.9.1", "factory-girl": "^5.0.4", "get-port": "^5.1.1", - "lodash": "4.17.21", "luxon": "^2.0.2", "redux": "^4.1.1", "redux-persist": "^6.0.0", @@ -54,7 +53,6 @@ "@types/factory-girl": "^5.0.12", "@quiet/node-common": "^4.0.3", "@types/jest": "^26.0.24", - "@types/lodash": "^4.17.24", "@types/luxon": "^2.0.0", "@types/redux-saga": "^0.10.5", "babel-jest": "^29.3.1", diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts index 1a143ecb0c..a4881df090 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.ts @@ -5,7 +5,6 @@ import { userProfileSelectors } from './userProfile.selectors' import { SocketActions, SocketEvents, SocketEventsMap, UserProfile, UserProfilesUpdatedPayload } from '@quiet/types' import { applyEmitParams, Socket } from '../../../types' import { usersActions } from '../users.slice' -import * as _ from 'lodash' const logger = createLogger('updateUserProfilesSaga') @@ -47,7 +46,7 @@ export function* updateUserProfilesSaga(socket: Socket, action: PayloadAction Date: Thu, 2 Apr 2026 20:06:00 -0400 Subject: [PATCH 46/92] add some additional test coverage --- .../src/nest/auth/sigchain.service.spec.ts | 63 +++++++++ .../connections-manager.service.spec.ts | 62 ++++++++ .../backend/src/nest/qps/qps.service.spec.ts | 35 +++++ .../backend/src/nest/qss/qss.service.spec.ts | 132 +++++++++++++++++- packages/mobile/src/setupTests.tsx | 3 + .../startConnection.saga.test.ts | 123 ++++++++++++++++ .../startConnection/startConnection.saga.ts | 2 +- .../saveDeviceCredentials.saga.test.ts | 28 ++++ .../saveKeysInKeychain.saga.test.ts | 27 ++++ .../saveUserMetadataNatively.saga.test.ts | 25 ++++ .../updateUserProfiles.saga.test.ts | 73 ++++++++-- 11 files changed, 558 insertions(+), 15 deletions(-) create mode 100644 packages/mobile/src/store/init/startConnection/startConnection.saga.test.ts create mode 100644 packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.test.ts create mode 100644 packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.test.ts create mode 100644 packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.test.ts diff --git a/packages/backend/src/nest/auth/sigchain.service.spec.ts b/packages/backend/src/nest/auth/sigchain.service.spec.ts index 5972869d83..52ece8eaa1 100644 --- a/packages/backend/src/nest/auth/sigchain.service.spec.ts +++ b/packages/backend/src/nest/auth/sigchain.service.spec.ts @@ -7,6 +7,8 @@ import { LocalDbModule } from '../local-db/local-db.module' import { TestModule } from '../common/test.module' import { SigChainModule } from './sigchain.service.module' import { SigChain } from './sigchain' +import { SocketEvents } from '@quiet/types' +import waitForExpect from 'wait-for-expect' const logger = createLogger('auth:sigchainManager.spec') @@ -128,4 +130,65 @@ describe('SigChainService - listener lifecycle', () => { expect(chainA.listenerCount('updated')).toBe(1) expect(chainB.listenerCount('updated')).toBe(0) }) + + it('does not emit iOS-native key or device events on non-ios platforms', async () => { + const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit') + + await sigChainService.createChain('desktopOnly', 'alice', true) + + expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)).toHaveLength(0) + expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.DEVICE_CREDENTIALS_UPDATED)).toHaveLength(0) + }) + + it('emits new keys to iOS once and does not resend already-stored keys', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit') + const chain = await sigChainService.createChain('iosKeys', 'alice', true) + const teamId = chain.team!.id + + await waitForExpect(async () => { + const keyCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED) + expect(keyCalls).toHaveLength(1) + expect((keyCalls[0][1] as { keys: unknown[] }).keys.length).toBeGreaterThan(0) + const storedKeys = await localDbService.getKeysStoredInKeychain(teamId) + expect(storedKeys).toHaveLength((keyCalls[0][1] as { keys: unknown[] }).keys.length) + }) + + const storedKeysAfterFirstUpdate = await localDbService.getKeysStoredInKeychain(teamId) + + chain.emit('updated') + + await new Promise(resolve => setTimeout(resolve, 25)) + + expect(emitSpy.mock.calls.filter(([event]) => event === SocketEvents.KEYS_UPDATED)).toHaveLength(1) + expect(await localDbService.getKeysStoredInKeychain(teamId)).toEqual(storedKeysAfterFirstUpdate) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + + it('emits device credentials for the NSE on ios', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + const emitSpy = jest.spyOn(sigChainService.serverIoProvider.io, 'emit') + const chain = await sigChainService.createChain('iosDeviceCredentials', 'alice', true) + + await waitForExpect(() => { + const deviceCalls = emitSpy.mock.calls.filter(([event]) => event === SocketEvents.DEVICE_CREDENTIALS_UPDATED) + expect(deviceCalls).toHaveLength(1) + expect(deviceCalls[0][1]).toEqual({ + deviceId: chain.device.deviceId, + teamId: chain.team!.id, + signingPrivateKey: chain.device.keys.signature.secretKey, + }) + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) }) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index bdbfdc0259..6fca1ceb4f 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -166,6 +166,68 @@ describe('ConnectionsManagerService', () => { } }) + it('falls back to the stored community QSS endpoint when no authoritative endpoint is available', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + value: 'ios', + }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEndpoint: 'ws://community.example/ws', + }) + await localDbService.setCurrentCommunityId(community.id) + + qssService._qssEndpoint = undefined as any + + const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') + + await (connectionsManagerService as any).emitNseQssUrl() + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'http://community.example/ws', + }) + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }) + } + }) + + it('skips NSE QSS URL emission when no valid ws or wss endpoint can be derived', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { + value: 'ios', + }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEndpoint: 'https://community.example/api', + }) + await localDbService.setCurrentCommunityId(community.id) + + qssService._qssEndpoint = 'https://authoritative.example/api' + + const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') + + await (connectionsManagerService as any).emitNseQssUrl() + + expect(emitSpy).not.toHaveBeenCalledWith( + SocketEvents.NSE_QSS_URL_UPDATED, + expect.objectContaining({ teamId: 'team-id' }) + ) + } finally { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }) + } + }) + it('pauses and resumes qss alongside the mobile lifecycle', async () => { const closeSocketSpy = jest.spyOn(connectionsManagerService, 'closeSocket').mockResolvedValue() const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue() diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index 89730437d1..d2ef8a20eb 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -428,4 +428,39 @@ describe('QPSService', () => { expect(sendBatchPushSpy).toHaveBeenCalledWith(TEAM_ID) }) }) + + describe('sendPush', () => { + beforeEach(() => { + qssClient.connected = true + qssClient.sendMessage.mockResolvedValue(pushSuccessResponse) + }) + + it('strips qssUrl from single push data before sending to QPS', async () => { + await qpsService.sendPush('ucan-user-a', 'title', 'body', { + cid: 'cid-1', + qssUrl: 'https://untrusted.example', + }) + + expect(qssClient.sendMessage).toHaveBeenCalledWith( + WebsocketEvents.SEND_PUSH, + expect.objectContaining({ + payload: { + ucan: 'ucan-user-a', + title: 'title', + body: 'body', + data: { cid: 'cid-1' }, + }, + }), + true + ) + }) + + it('skips single push when QSS is not connected', async () => { + qssClient.connected = false + + await qpsService.sendPush('ucan-user-a', 'title', 'body', { cid: 'cid-1' }) + + expect(qssClient.sendMessage).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index 052a62316d..d0047b953c 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -21,7 +21,7 @@ import { QSSOperationResult, } from './qss.types' import { createLogger } from '../common/logger' -import { Community, Identity } from '@quiet/types' +import { Community, Identity, SocketEvents } from '@quiet/types' import { getReduxStoreFactory, prepareStore, Store } from '@quiet/state-manager' import { FactoryGirl } from 'factory-girl' import { DateTime } from 'luxon' @@ -640,6 +640,7 @@ describe('QSSService', () => { expect(initStatusOrig.qssSetup).toBeTruthy() const syncSeq = 41 await localDbService.setLastSyncSeq(sigchainService.team.id, 40) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') mockedJoinStatus = jest.spyOn(qssService, 'joinStatus').mockReturnValue(JoinStatus.JOINED) mockedSendMessage = jest @@ -708,6 +709,10 @@ describe('QSSService', () => { expect(result).toBe(true) expect(mockedSendMessage).toHaveBeenCalledTimes(1) expect(await localDbService.getLastSyncSeq(sigchainService.team.id)).toBe(syncSeq) + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_SYNC_SEQ_UPDATED, { + teamId: sigchainService.team.id, + lastSyncSeq: syncSeq, + }) const pendingMessages = await localDbService.getPendingQssLogSyncMessages() expect(pendingMessages).toEqual({}) @@ -754,6 +759,131 @@ describe('QSSService', () => { }) }) + it(`reconciles by pull when a fanout arrives before a sync-seq baseline is established`, async () => { + await initCommunity({ qssEnabled: true, qssSetup: true }) + const teamId = sigchainService.activeChain.team!.id + const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined) + + jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(true) + + qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + payload: { + teamId, + hash: 'fanout-baseline-hash', + hashedDbId: 'fanout-baseline-db-id', + encEntry: { + encrypted: { + contents: new Uint8Array(), + scope: { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + generation: 1, + }, + }, + signature: { + signature: 'fanout-baseline-sig' as Base58, + author: { type: 'USER', name: 'fanout-user' } as any, + }, + ts: DateTime.utc().toMillis(), + userId: sigchainService.user.userId, + teamId, + }, + syncSeq: 1, + }, + } satisfies LogEntrySyncMessage) + + await waitForExpect(() => { + expect(pullSpy).toHaveBeenCalledWith(teamId) + }) + expect(await localDbService.getLastSyncSeq(teamId)).toBeNull() + }) + + it(`reconciles by pull when a sync-seq gap is detected from fanout`, async () => { + await initCommunity({ qssEnabled: true, qssSetup: true }) + const teamId = sigchainService.activeChain.team!.id + await localDbService.setLastSyncSeq(teamId, 5) + const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined) + + jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(true) + + qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + payload: { + teamId, + hash: 'fanout-gap-hash', + hashedDbId: 'fanout-gap-db-id', + encEntry: { + encrypted: { + contents: new Uint8Array(), + scope: { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + generation: 1, + }, + }, + signature: { + signature: 'fanout-gap-sig' as Base58, + author: { type: 'USER', name: 'fanout-user' } as any, + }, + ts: DateTime.utc().toMillis(), + userId: sigchainService.user.userId, + teamId, + }, + syncSeq: 7, + }, + } satisfies LogEntrySyncMessage) + + await waitForExpect(() => { + expect(pullSpy).toHaveBeenCalledWith(teamId) + }) + expect(await localDbService.getLastSyncSeq(teamId)).toBe(5) + }) + + it(`reconciles by pull when fanout ingest fails even with a contiguous sync seq`, async () => { + await initCommunity({ qssEnabled: true, qssSetup: true }) + const teamId = sigchainService.activeChain.team!.id + await localDbService.setLastSyncSeq(teamId, 5) + const pullSpy = jest.spyOn(qssService as any, '_pullLatestLogEntriesForTeam').mockResolvedValue(undefined) + + jest.spyOn(orbitDbService, 'handleFanoutMessage').mockResolvedValue(false) + + qssClient.emit(WebsocketEvents.LOG_ENTRY_SYNC, { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + payload: { + teamId, + hash: 'fanout-failure-hash', + hashedDbId: 'fanout-failure-db-id', + encEntry: { + encrypted: { + contents: new Uint8Array(), + scope: { + type: EncryptionScopeType.ROLE, + name: RoleName.MEMBER, + generation: 1, + }, + }, + signature: { + signature: 'fanout-failure-sig' as Base58, + author: { type: 'USER', name: 'fanout-user' } as any, + }, + ts: DateTime.utc().toMillis(), + userId: sigchainService.user.userId, + teamId, + }, + syncSeq: 6, + }, + } satisfies LogEntrySyncMessage) + + await waitForExpect(() => { + expect(pullSpy).toHaveBeenCalledWith(teamId) + }) + expect(await localDbService.getLastSyncSeq(teamId)).toBe(5) + }) + it(`fails to send log sync to QSS and writes pending message to local DB`, async () => { await initCommunity({ qssEnabled: true, qssSetup: true }) const initStatusOrig = await qssService.getQssInitStatus() diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index eb43ff26bb..73675155bd 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -47,6 +47,9 @@ jest.mock('react-native', () => { requestNotificationPermission: jest.fn(), checkNotificationPermission: jest.fn(), handleIncomingEvents: jest.fn(), + saveKeysInKeychain: jest.fn(), + saveDeviceCredentials: jest.fn(), + saveUserMetadata: jest.fn(), saveNseQssUrl: jest.fn(), saveNseLastSyncSeq: jest.fn(), } diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.test.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.test.ts new file mode 100644 index 0000000000..6f44c2f3f5 --- /dev/null +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.test.ts @@ -0,0 +1,123 @@ +import { NativeModules } from 'react-native' +import { SocketEvents } from '@quiet/types' + +import { subscribeSocketLifecycle } from './startConnection.saga' +import { initActions, WebsocketConnectionPayload } from '../init.slice' +import { keysActions } from '../../keys/keys.slice' +import { usersMetadataActions } from '../../userMetadata/usersMetadata.slice' + +class MockSocket { + public id = 'socket-1' + private readonly handlers: Map void>> = new Map() + + public on = jest.fn((event: string, handler: (...args: any[]) => void) => { + const existing = this.handlers.get(event) ?? new Set() + existing.add(handler) + this.handlers.set(event, existing) + return this + }) + + public off = jest.fn((event: string) => { + this.handlers.delete(event) + return this + }) + + public trigger(event: string, ...args: any[]): void { + for (const handler of this.handlers.get(event) ?? []) { + void handler(...args) + } + } +} + +const takeFromChannel = (channel: { take: (callback: (input: T) => void) => void }): Promise => + new Promise(resolve => { + channel.take(resolve) + }) + +describe('subscribeSocketLifecycle', () => { + const socketIOData: WebsocketConnectionPayload = { + dataPort: 11000, + socketIOSecret: 'secret', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('maps socket lifecycle and NSE events to mobile actions', async () => { + const socket = new MockSocket() + const channel = subscribeSocketLifecycle(socket as any, socketIOData) + + const connected = takeFromChannel(channel) + socket.trigger('connect') + await expect(connected).resolves.toEqual(initActions.setWebsocketConnected(socketIOData)) + + const keysUpdatedPayload = { keys: [{ keyName: 'quiet_team_secret', key: 'secret-key' }] } + const keysUpdated = takeFromChannel(channel) + socket.trigger(SocketEvents.KEYS_UPDATED, keysUpdatedPayload) + await expect(keysUpdated).resolves.toEqual(keysActions.saveKeysInKeychain(keysUpdatedPayload)) + + const credentialsPayload = { + deviceId: 'device-id', + teamId: 'team-id', + signingPrivateKey: 'private-signing-key', + } + const credentialsUpdated = takeFromChannel(channel) + socket.trigger(SocketEvents.DEVICE_CREDENTIALS_UPDATED, credentialsPayload) + await expect(credentialsUpdated).resolves.toEqual(keysActions.saveDeviceCredentials(credentialsPayload)) + + const userProfilesPayload = { + new: [{ userId: 'new-user', nickname: 'Alice' }], + updates: [{ userId: 'updated-user', nickname: 'Bob' }], + } + const userProfilesUpdated = takeFromChannel(channel) + socket.trigger(SocketEvents.USER_PROFILES_UPDATED, userProfilesPayload) + await expect(userProfilesUpdated).resolves.toEqual( + usersMetadataActions.saveUserMetadataNatively(userProfilesPayload) + ) + + const disconnected = takeFromChannel(channel) + socket.trigger('disconnect', 'transport close') + await expect(disconnected).resolves.toEqual(initActions.suspendWebsocketConnection()) + + channel.close() + }) + + it('stores NSE QSS url and sync seq in native shared storage', async () => { + const socket = new MockSocket() + const channel = subscribeSocketLifecycle(socket as any, socketIOData) + + socket.trigger(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'https://community.example', + }) + await Promise.resolve() + + expect(NativeModules.CommunicationModule.saveNseQssUrl).toHaveBeenCalledWith('team-id', 'https://community.example') + + socket.trigger(SocketEvents.NSE_SYNC_SEQ_UPDATED, { + teamId: 'team-id', + lastSyncSeq: 42, + }) + await Promise.resolve() + + expect(NativeModules.CommunicationModule.saveNseLastSyncSeq).toHaveBeenCalledWith('team-id', 42) + + channel.close() + }) + + it('unsubscribes all registered listeners when the channel closes', () => { + const socket = new MockSocket() + const channel = subscribeSocketLifecycle(socket as any, socketIOData) + + channel.close() + + expect(socket.off).toHaveBeenCalledWith('connect') + expect(socket.off).toHaveBeenCalledWith('disconnect') + expect(socket.off).toHaveBeenCalledWith(SocketEvents.KEYS_UPDATED) + expect(socket.off).toHaveBeenCalledWith(SocketEvents.DEVICE_CREDENTIALS_UPDATED) + expect(socket.off).toHaveBeenCalledWith(SocketEvents.USER_PROFILES_UPDATED) + expect(socket.off).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED) + expect(socket.off).toHaveBeenCalledWith(SocketEvents.NSE_SYNC_SEQ_UPDATED) + }) +}) diff --git a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts index 941cae33b7..60397e5b0e 100644 --- a/packages/mobile/src/store/init/startConnection/startConnection.saga.ts +++ b/packages/mobile/src/store/init/startConnection/startConnection.saga.ts @@ -86,7 +86,7 @@ function* handleSocketLifecycleActions(socket: Socket, socketIOData: WebsocketCo } } -function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnectionPayload) { +export function subscribeSocketLifecycle(socket: Socket, socketIOData: WebsocketConnectionPayload) { let socket_id: string | undefined return eventChannel< diff --git a/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.test.ts b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.test.ts new file mode 100644 index 0000000000..8caff037b3 --- /dev/null +++ b/packages/mobile/src/store/keys/saveDeviceCredentials/saveDeviceCredentials.saga.test.ts @@ -0,0 +1,28 @@ +import { NativeModules } from 'react-native' +import { expectSaga } from 'redux-saga-test-plan' + +import { saveDeviceCredentialsSaga } from './saveDeviceCredentials.saga' +import { keysActions } from '../keys.slice' + +describe('saveDeviceCredentialsSaga', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('stores device credentials in the iOS keychain bridge', async () => { + const payload = { + deviceId: 'device-id', + teamId: 'team-id', + signingPrivateKey: 'private-signing-key', + } + + await expectSaga(saveDeviceCredentialsSaga, keysActions.saveDeviceCredentials(payload)) + .call( + NativeModules.CommunicationModule.saveDeviceCredentials, + payload.deviceId, + payload.teamId, + payload.signingPrivateKey + ) + .run() + }) +}) diff --git a/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.test.ts b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.test.ts new file mode 100644 index 0000000000..826c9e2e76 --- /dev/null +++ b/packages/mobile/src/store/keys/saveKeysInKeychain/saveKeysInKeychain.saga.test.ts @@ -0,0 +1,27 @@ +import { NativeModules } from 'react-native' +import { expectSaga } from 'redux-saga-test-plan' + +import { saveKeysInKeychainSaga } from './saveKeysInKeychain.saga' +import { keysActions } from '../keys.slice' + +describe('saveKeysInKeychainSaga', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('serializes key payloads before saving them to the iOS keychain', async () => { + const payload = { + keys: [ + { keyName: 'quiet_team_secret', key: 'secret-key' }, + { keyName: 'quiet_user_public', key: 'public-key' }, + ], + } + + await expectSaga(saveKeysInKeychainSaga, keysActions.saveKeysInKeychain(payload)) + .call( + NativeModules.CommunicationModule.saveKeysInKeychain, + payload.keys.map(key => JSON.stringify(key)) + ) + .run() + }) +}) diff --git a/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.test.ts b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.test.ts new file mode 100644 index 0000000000..bac629dbb8 --- /dev/null +++ b/packages/mobile/src/store/userMetadata/saveUserMetadataNatively/saveUserMetadataNatively.saga.test.ts @@ -0,0 +1,25 @@ +import { NativeModules } from 'react-native' +import { expectSaga } from 'redux-saga-test-plan' + +import { saveUserMetadataNativelySaga } from './saveUserMetadataNatively.saga' +import { usersMetadataActions } from '../usersMetadata.slice' + +describe('saveUserMetadataNativelySaga', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('serializes new and updated profiles before storing them natively', async () => { + const payload = { + new: [{ userId: 'new-user', nickname: 'Alice' }], + updates: [{ userId: 'updated-user', nickname: 'Bob' }], + } + + await expectSaga(saveUserMetadataNativelySaga, usersMetadataActions.saveUserMetadataNatively(payload)) + .call( + NativeModules.CommunicationModule.saveUserMetadata, + payload.new.concat(payload.updates).map(profile => JSON.stringify(profile)) + ) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts index 48d176d5be..75a09bffed 100644 --- a/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts +++ b/packages/state-manager/src/sagas/users/userProfile/updateUserProfiles.saga.test.ts @@ -1,6 +1,6 @@ import { expectSaga } from 'redux-saga-test-plan' import { type FactoryGirl } from 'factory-girl' -import { UserProfile, SocketActions, Identity } from '@quiet/types' +import { UserProfile, SocketActions } from '@quiet/types' import { usersActions } from '../users.slice' import { updateUserProfilesSaga } from './updateUserProfiles.saga' @@ -11,7 +11,6 @@ import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' import { getBaseTypesFactory } from '../../../utils/tests/factories' import { combineReducers } from 'redux' import { userProfileSelectors } from './userProfile.selectors' -import { createLogger } from '../../../utils/logger' describe('updateUserProfilesSaga', () => { let store: Store @@ -20,8 +19,6 @@ describe('updateUserProfilesSaga', () => { let userProfile: UserProfile let userId: string - const logger = createLogger('updateUserProfilesSaga:test') - beforeEach(async () => { socket = new MockedSocket() store = prepareStore().store @@ -47,8 +44,6 @@ describe('updateUserProfilesSaga', () => { }, } - let userProfilesSelectCalls = 0 - await expectSaga( updateUserProfilesSaga, socket as unknown as Socket, @@ -61,7 +56,6 @@ describe('updateUserProfilesSaga', () => { { select: ({ selector }: any, next: any) => { if (selector === userProfileSelectors.userProfiles) { - userProfilesSelectCalls += 1 return existingProfiles } return next() @@ -101,8 +95,6 @@ describe('updateUserProfilesSaga', () => { }, } - let userProfilesSelectCalls = 0 - await expectSaga( updateUserProfilesSaga, socket as unknown as Socket, @@ -115,7 +107,6 @@ describe('updateUserProfilesSaga', () => { { select: ({ selector }: any, next: any) => { if (selector === userProfileSelectors.userProfiles) { - userProfilesSelectCalls += 1 return existingProfiles } return next() @@ -136,6 +127,65 @@ describe('updateUserProfilesSaga', () => { .run() }) + it('should preserve profilePhoto.path and emit an update when CID is unchanged but other fields change', async () => { + const existingProfiles = { + [userId]: userProfile, + } + + const updatedProfile: UserProfile = { + ...userProfile, + nickname: `${userProfile.nickname}-updated`, + profilePhoto: { + ...userProfile.profilePhoto!, + path: null, + }, + } + + const expectedProfile: UserProfile = { + ...updatedProfile, + profilePhoto: { + ...updatedProfile.profilePhoto!, + path: userProfile.profilePhoto!.path, + }, + } + + await expectSaga( + updateUserProfilesSaga, + socket as unknown as Socket, + usersActions.updateUserProfiles([updatedProfile]) + ) + .withReducer(combineReducers(testReducers)) + .withState(store.getState()) + .provide([ + { + select: ({ selector }: any, next: any) => { + if (selector === userProfileSelectors.userProfiles) { + return existingProfiles + } + return next() + }, + }, + ]) + .apply.like({ + context: socket, + fn: socket.emit, + args: [ + SocketActions.USER_PROFILES_UPDATED, + { + new: [], + updates: [expectedProfile], + }, + ], + }) + .put.like({ + action: { + type: usersActions.setUserProfiles.type, + payload: [expectedProfile], + }, + }) + .run() + }) + it('should send new profile via socket to ios', async () => { const existingProfiles = { [userId]: userProfile, @@ -143,8 +193,6 @@ describe('updateUserProfilesSaga', () => { const newProfile = await baseTypesFactory.create('UserProfile') - let userProfilesSelectCalls = 0 - await expectSaga( updateUserProfilesSaga, socket as unknown as Socket, @@ -157,7 +205,6 @@ describe('updateUserProfilesSaga', () => { { select: ({ selector }: any, next: any) => { if (selector === userProfileSelectors.userProfiles) { - userProfilesSelectCalls += 1 return existingProfiles } return next() From 98de8af3017d702a50c79ca33590c8b54e5a4fff Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 6 Apr 2026 16:36:23 -0400 Subject: [PATCH 47/92] tombstone notification tokens when leaving; improve qss url passthrough; improve listener lifecycle when leaving, opening, and closing; fix unhandled rejections in dlq; cleanup keychain data when leaving or on fresh install --- .../connections-manager.service.spec.ts | 52 +++++- .../connections-manager.service.ts | 18 +- .../backend/src/nest/qps/qps.service.spec.ts | 55 ++++++ packages/backend/src/nest/qps/qps.service.ts | 70 +++++++- packages/backend/src/nest/qss/qss.service.ts | 121 +++++++++++++- .../nest/storage/channels/channel.store.ts | 19 ++- .../nest/storage/channels/channels.service.ts | 41 +++-- .../notificationTokens.store.spec.ts | 9 + .../notifications/notificationTokens.store.ts | 37 ++++- .../identity/lfa/lfa-identity.service.ts | 23 ++- .../storage/orbitDb/orbitDb.service.spec.ts | 7 + .../nest/storage/orbitDb/orbitDb.service.ts | 9 +- .../src/nest/storage/storage.service.ts | 6 + .../storage/userProfile/userProfile.store.ts | 22 ++- packages/mobile/ios/CommunicationBridge.m | 1 + packages/mobile/ios/CommunicationModule.swift | 72 +++++++- packages/mobile/ios/KeychainHandler.swift | 157 +++++++++++++----- .../ios/Quiet.xcodeproj/project.pbxproj | 9 +- .../ios/Quiet/AppDelegate+Firebase.swift | 15 -- packages/mobile/ios/Quiet/AppDelegate.m | 2 + .../ios/Quiet/FirebaseMessagingModule.m | 2 - .../ios/Quiet/FirebaseMessagingModule.swift | 4 - .../KeychainHelper.swift | 81 --------- .../NSEKeychainHelper.swift | 11 +- .../NotificationService.swift | 6 +- packages/mobile/ios/UserMetadataHandler.swift | 23 +++ packages/mobile/src/setupTests.tsx | 1 + .../leaveCommunity.saga.test.ts | 38 +++++ .../leaveCommunity/leaveCommunity.saga.ts | 10 ++ .../src/sagas/users/users.slice.ts | 5 +- 30 files changed, 704 insertions(+), 222 deletions(-) delete mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/KeychainHelper.swift create mode 100644 packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.test.ts diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index 6fca1ceb4f..25576ef793 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -2,7 +2,7 @@ import { jest } from '@jest/globals' import { Test, TestingModule } from '@nestjs/testing' import { getReduxStoreFactory, prepareStore, type Store } from '@quiet/state-manager' -import { CommunityOwnership, SocketEvents, type Community, type Identity } from '@quiet/types' +import { CommunityOwnership, SocketActions, SocketEvents, type Community, type Identity } from '@quiet/types' import { type FactoryGirl } from 'factory-girl' import { TestModule } from '../common/test.module' import { removeFilesFromDir } from '../common/utils' @@ -19,6 +19,7 @@ import { SigChainService } from '../auth/sigchain.service' import { StorageModule } from '../storage/storage.module' import { QSSService } from '../qss/qss.service' import { QSSOperationResult } from '../qss/qss.types' +import { QPSService } from '../qps/qps.service' const logger = createLogger('connections-manager.service.spec') @@ -34,6 +35,7 @@ describe('ConnectionsManagerService', () => { let communityRootCa: string let sigChainService: SigChainService let qssService: QSSService + let qpsService: QPSService beforeEach(async () => { jest.clearAllMocks() @@ -58,6 +60,7 @@ describe('ConnectionsManagerService', () => { localDbService = await module.resolve(LocalDbService) sigChainService = await module.resolve(SigChainService) qssService = await module.resolve(QSSService) + qpsService = await module.resolve(QPSService) localDbService.open() // initialize sigchain on local db @@ -246,4 +249,51 @@ describe('ConnectionsManagerService', () => { expect(libp2pResumeSpy).toHaveBeenCalledTimes(1) expect(qssResumeSpy).toHaveBeenCalledTimes(1) }) + + it('attempts notification token tombstoning before closing services and still leaves if it is not acked', async () => { + const tombstoneSpy = jest.spyOn(qpsService, 'tombstoneCurrentUserNotificationTokens').mockResolvedValue(false) + const closeAllServicesSpy = jest.spyOn(connectionsManagerService, 'closeAllServices').mockResolvedValue() + const storageCleanSpy = jest.spyOn(connectionsManagerService['storageService'], 'clean').mockResolvedValue() + const cleanDatastoreSpy = jest.spyOn(connectionsManagerService.libp2pService, 'cleanDatastore').mockResolvedValue() + const closeDatastoreSpy = jest.spyOn(connectionsManagerService.libp2pService, 'closeDatastore').mockResolvedValue() + const purgeDataSpy = jest + .spyOn(connectionsManagerService['storageService'], 'purgeData') + .mockImplementation(() => {}) + const resetHiddenServicesSpy = jest + .spyOn(connectionsManagerService['tor'], 'resetHiddenServices') + .mockImplementation(() => {}) + const resetStateSpy = jest.spyOn(connectionsManagerService, 'resetState').mockResolvedValue() + const localDbOpenSpy = jest.spyOn(connectionsManagerService['localDbService'], 'open').mockResolvedValue() + const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue() + + await connectionsManagerService.leaveCommunity() + + expect(tombstoneSpy).toHaveBeenCalledTimes(1) + expect(closeAllServicesSpy).toHaveBeenCalledTimes(1) + expect(tombstoneSpy.mock.invocationCallOrder[0]).toBeLessThan(closeAllServicesSpy.mock.invocationCallOrder[0]) + + storageCleanSpy.mockRestore() + cleanDatastoreSpy.mockRestore() + closeDatastoreSpy.mockRestore() + purgeDataSpy.mockRestore() + resetHiddenServicesSpy.mockRestore() + resetStateSpy.mockRestore() + localDbOpenSpy.mockRestore() + openSocketSpy.mockRestore() + }) + + it('returns false instead of rejecting when leaveCommunity fails through the socket listener', async () => { + await connectionsManagerService.init() + + const leaveCommunitySpy = jest + .spyOn(connectionsManagerService, 'leaveCommunity') + .mockRejectedValueOnce(new Error('qss tombstone failed')) + const callback = jest.fn() + + connectionsManagerService['socketService'].emit(SocketActions.LEAVE_COMMUNITY, callback) + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(leaveCommunitySpy).toHaveBeenCalledTimes(1) + expect(callback).toHaveBeenCalledWith(false) + }) }) diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index 8d224297a0..a98dd60192 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -72,6 +72,7 @@ import { SigChainService } from '../auth/sigchain.service' import { QSSService } from '../qss/qss.service' import { RoleName } from '../auth/services/roles/roles' import { QSSEvents } from '../qss/qss.types' +import { QPSService } from '../qps/qps.service' /** * A monolith service that handles lots of events received from the state-manager. @@ -97,7 +98,8 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI private readonly storageService: StorageService, private readonly tor: Tor, private readonly sigChainService: SigChainService, - private readonly qssService: QSSService + private readonly qssService: QSSService, + private readonly qpsService: QPSService ) { super() } @@ -314,6 +316,11 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI public async leaveCommunity(): Promise { this.logger.info('Running leaveCommunity') + this.logger.info('Tombstoning notification tokens before leave') + const tombstoneAcked = await this.qpsService.tombstoneCurrentUserNotificationTokens() + if (!tombstoneAcked) { + this.logger.warn('Proceeding with leave without confirmed notification token tombstone ack') + } await this.closeAllServices({ saveTor: true, closeDatastore: false, deleteChainFromDisk: true }) @@ -710,7 +717,7 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI return } - const wsUrl = this.qssService.qssEndpoint ?? community.qssEndpoint ?? this.qssEndpoint + const wsUrl = community.qssEndpoint ?? this.qssEndpoint ?? this.qssService.qssEndpoint const qssUrl = this.getNseQssUrl(wsUrl) if (qssUrl == null) { this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') @@ -763,7 +770,12 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI this.socketService.on(SocketActions.LEAVE_COMMUNITY, async (callback: (closed: boolean) => void) => { this.logger.info(`socketService - ${SocketActions.LEAVE_COMMUNITY}`) - callback(await this.leaveCommunity()) + try { + callback(await this.leaveCommunity()) + } catch (e) { + this.logger.error('Error while handling leave community request', e) + callback(false) + } }) // Local First Auth diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index d2ef8a20eb..7ad334ef25 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -4,6 +4,7 @@ import { QPSService } from './qps.service' import { CommunityOperationStatus, QSSEvents, WebsocketEvents } from '../qss/qss.types' import { RoleName } from '../auth/services/roles/roles' import { DateTime } from 'luxon' +import { JoinStatus } from '../libp2p/libp2p.auth' /** * Lightweight mocks — avoid bootstrapping the full NestJS module graph. @@ -26,6 +27,8 @@ class MockSigChainService extends EventEmitter { class MockQSSService extends EventEmitter { on = this.addListener + waitForLogEntrySyncAck = jest.fn().mockResolvedValue(undefined) + joinStatus = jest.fn().mockReturnValue(JoinStatus.JOINED) emitEvent(event: QSSEvents, payload?: any) { this.emit(event, payload) } @@ -35,6 +38,7 @@ class MockSocketService extends EventEmitter {} class MockNotificationTokensStore { addToken = jest.fn() + tombstoneUser = jest.fn().mockResolvedValue('tombstone-hash') getAllEntries = jest.fn().mockResolvedValue([]) } @@ -45,6 +49,7 @@ describe('QPSService', () => { let sigChainService: MockSigChainService let socketService: MockSocketService let notificationTokensStore: MockNotificationTokensStore + let originalPlatform: NodeJS.Platform const TOKEN = 'fake-device-token-abc123' const TEAM_ID = 'test-team-id' @@ -71,6 +76,7 @@ describe('QPSService', () => { beforeEach(() => { jest.clearAllMocks() + originalPlatform = process.platform qssClient = new MockQSSClient() qssService = new MockQSSService() sigChainService = new MockSigChainService() @@ -92,6 +98,10 @@ describe('QPSService', () => { qpsService.onModuleInit() }) + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + }) + describe('register', () => { it('sends immediately when ready', async () => { setReady() @@ -283,6 +293,51 @@ describe('QPSService', () => { }) }) + describe('tombstoneCurrentUserNotificationTokens', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'ios' }) + setReady() + }) + + it('writes a tombstone and waits for the matching QSS ack', async () => { + await expect(qpsService.tombstoneCurrentUserNotificationTokens()).resolves.toBe(true) + + expect(notificationTokensStore.tombstoneUser).toHaveBeenCalledWith('test-user-id') + expect(qssService.waitForLogEntrySyncAck).toHaveBeenCalledWith('tombstone-hash', 5000) + }) + + it('allows leave to continue if QSS is not connected', async () => { + qssClient.connected = false + + await expect(qpsService.tombstoneCurrentUserNotificationTokens()).resolves.toBe(false) + expect(notificationTokensStore.tombstoneUser).not.toHaveBeenCalled() + }) + + it('allows leave to continue if QSS auth is not joined', async () => { + qssService.joinStatus.mockReturnValueOnce(JoinStatus.NOT_STARTED) + + await expect(qpsService.tombstoneCurrentUserNotificationTokens()).resolves.toBe(false) + expect(notificationTokensStore.tombstoneUser).not.toHaveBeenCalled() + }) + + it('allows leave to continue if the QSS ack does not arrive before the timeout', async () => { + qssService.waitForLogEntrySyncAck.mockRejectedValueOnce(new Error('Timed out waiting for QSS ack')) + + await expect(qpsService.tombstoneCurrentUserNotificationTokens()).resolves.toBe(false) + + expect(notificationTokensStore.tombstoneUser).toHaveBeenCalledWith('test-user-id') + expect(qssService.waitForLogEntrySyncAck).toHaveBeenCalledWith('tombstone-hash', 5000) + }) + + it('allows leave to continue if writing the tombstone fails', async () => { + notificationTokensStore.tombstoneUser.mockRejectedValueOnce(new Error('store unavailable')) + + await expect(qpsService.tombstoneCurrentUserNotificationTokens()).resolves.toBe(false) + + expect(qssService.waitForLogEntrySyncAck).not.toHaveBeenCalled() + }) + }) + describe('sendBatchPush', () => { const UCANS = ['ucan-user-a', 'ucan-user-b'] diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts index 859bc1d167..31fd893101 100644 --- a/packages/backend/src/nest/qps/qps.service.ts +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -17,9 +17,11 @@ import { SigChainService } from '../auth/sigchain.service' import { RoleName } from '../auth/services/roles/roles' import { NotificationTokensStore } from '../storage/notifications/notificationTokens.store' import { QSSService } from '../qss/qss.service' +import { JoinStatus } from '../libp2p/libp2p.auth' const BUNDLE_ID = 'com.quietmobile' const PUSH_BATCH_SIZE = 500 // FCM allows up to 500 tokens per batch request +const LEAVE_TOMBSTONE_ACK_TIMEOUT_MS = 5_000 @Injectable() export class QPSService implements OnModuleInit { @@ -75,6 +77,68 @@ export class QPSService implements OnModuleInit { return this._register(deviceToken) } + public async tombstoneCurrentUserNotificationTokens(): Promise { + if (!this.enabled) { + this.logger.info('QPS not enabled, skipping notification token tombstone') + this._pendingDeviceToken = undefined + return true + } + + if ((process.platform as string) !== 'ios') { + this.logger.info('Notification token tombstone is only necessary on iOS, skipping') + this._pendingDeviceToken = undefined + return true + } + + const teamId = this.sigChainService.team?.id + const userId = this.sigChainService.user.userId + if (teamId == null) { + this.logger.warn('Cannot tombstone notification tokens before leave: no active team id') + this._pendingDeviceToken = undefined + return false + } + + if (!this.qssClient.connected) { + this.logger.warn(`Cannot tombstone notification tokens before leave: QSS is not connected for team ${teamId}`) + this._pendingDeviceToken = undefined + return false + } + + if (this.qssService.joinStatus(teamId) !== JoinStatus.JOINED) { + this.logger.warn(`Cannot tombstone notification tokens before leave: QSS auth is not joined for team ${teamId}`) + this._pendingDeviceToken = undefined + return false + } + + if (!this._hasMemberKey()) { + this.logger.warn(`Cannot tombstone notification tokens before leave: member key unavailable for team ${teamId}`) + this._pendingDeviceToken = undefined + return false + } + + try { + this.logger.info(`Tombstoning notification tokens before leave for user ${userId} on team ${teamId}`) + const tombstoneHash = await this.notificationTokensStore.tombstoneUser(userId) + + try { + await this.qssService.waitForLogEntrySyncAck(tombstoneHash, LEAVE_TOMBSTONE_ACK_TIMEOUT_MS) + this.logger.info(`Notification token tombstone acknowledged by QSS for user ${userId} on team ${teamId}`) + return true + } catch (err) { + this.logger.warn( + `Notification token tombstone was not acknowledged within ${LEAVE_TOMBSTONE_ACK_TIMEOUT_MS}ms for user ${userId} on team ${teamId}; continuing leave`, + err + ) + return false + } + } catch (err) { + this.logger.warn(`Failed to tombstone notification tokens before leave for user ${userId} on team ${teamId}`, err) + return false + } finally { + this._pendingDeviceToken = undefined + } + } + private async _flushPendingToken(): Promise { this.logger.debug('Checking if pending device token can be flushed') const hasPendingToken = this._pendingDeviceToken != undefined @@ -151,10 +215,9 @@ export class QPSService implements OnModuleInit { batches.push(ucans.slice(i, i + PUSH_BATCH_SIZE)) } - const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {} const mergedData: Record = { teamId, - ...safeData, + ...data, } this.logger.info( @@ -199,13 +262,12 @@ export class QPSService implements OnModuleInit { } try { - const { qssUrl: _ignoredQssUrl, ...safeData } = data ?? {} return await this.qssClient.sendMessage( WebsocketEvents.SEND_PUSH, { ts: DateTime.utc().toMillis(), status: CommunityOperationStatus.SENDING, - payload: { ucan, title, body, data: safeData }, + payload: { ucan, title, body, data }, }, true ) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 4fe6437aca..d3c048ac1e 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -88,6 +88,11 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul * Mutexes for createCommunity per teamId */ private _signInMutex: Mutex = new Mutex() + private readonly _logSyncWaiters: Map< + string, + { resolve: () => void; reject: (error: Error) => void; timeout: NodeJS.Timeout }[] + > = new Map() + private readonly _recentLogSyncResults: Map = new Map() private readonly logger = createLogger(`qss:service`) @@ -135,9 +140,17 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul const entries = Object.entries(unsentHashesByAddr) this.logger.info(`Found ${Object.entries(unsentHashesByAddr).length} unsent hashes to send to QSS`) const successes: Record = {} + const hashesToRemoveByAddr: Record = {} for (const [address, unsentHashes] of entries) { const successByAddr: string[] = [] + const hashesToRemove: string[] = [] const unsentEntries: LogEntry[] = await this.orbitDbService.getLogEntriesByHashes(address, unsentHashes) + const foundHashes = new Set(unsentEntries.map(entry => entry.hash)) + for (const hash of unsentHashes) { + if (!foundHashes.has(hash)) { + hashesToRemove.push(hash) + } + } for (const entry of unsentEntries) { const success = await this.sendLogEntrySyncMessage(logEntryToLogUpdate(entry, address)) if (success) { @@ -149,10 +162,14 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul if (successByAddr.length > 0) { successes[address] = successByAddr } + if (hashesToRemove.length > 0 || successByAddr.length > 0) { + hashesToRemoveByAddr[address] = [...hashesToRemove, ...successByAddr] + } } + const removeCount = Object.keys(hashesToRemoveByAddr).length const successCount = Object.keys(successes).length - if (successCount > 0) { - await this.localDbService.removePendingQssLogSyncMessages(successes) + if (removeCount > 0) { + await this.localDbService.removePendingQssLogSyncMessages(hashesToRemoveByAddr) } if (successCount < entries.length) { this.logger.warn(`Failed to send ${entries.length - successCount} entries to QSS, will retry later...`) @@ -730,6 +747,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul public async sendLogEntrySyncMessage(update: LogUpdate): Promise { if (!this.canConnect) { this.logger.info(`Can't send log sync message to QSS because QSS is not enabled for this community`) + this.recordLogSyncFailure(update.hash, `QSS is not enabled; cannot sync log entry ${update.hash}`) return } @@ -737,6 +755,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul if (!initStatus.qssEnabled) { this.logger.trace(`Can't sync to QSS because QSS is disabled on this community`) + this.recordLogSyncFailure(update.hash, `QSS is disabled for this community; cannot sync log entry ${update.hash}`) return } @@ -750,6 +769,10 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul this.logger.warn( `No sigchain present for team ${update.teamId}, cannot send ${update.hash} log sync message to QSS` ) + this.recordLogSyncFailure( + update.hash, + `No sigchain present for team ${update.teamId}; cannot sync ${update.hash}` + ) return } @@ -789,6 +812,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul const teamId = dataSyncMessage.payload?.teamId if (!this.connected) { this.logger.warn('QSS not connected, writing entry to dead letter queue', hash, teamId) + this.recordLogSyncFailure(hash, `QSS not connected; cannot sync log entry ${hash}`) try { await this.localDbService.addPendingQssLogSyncMessage(address, hash) } catch (e) { @@ -799,6 +823,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul if (this.joinStatus(teamId) !== JoinStatus.JOINED) { this.logger.warn('QSS not signed in, writing entry to dead letter queue', hash, teamId) + this.recordLogSyncFailure(hash, `QSS not signed in for team ${teamId}; cannot sync log entry ${hash}`) try { await this.localDbService.addPendingQssLogSyncMessage(address, hash) } catch (e) { @@ -817,14 +842,17 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul let success = false if (dataSyncAck == null) { this.logger.error('Error while sending a log sync to QSS', hash, teamId) + this.recordLogSyncFailure(hash, `No QSS ack received for log entry ${hash}`) } else if (dataSyncAck.status !== CommunityOperationStatus.SUCCESS) { this.logger.error(`Error while sending a log sync to QSS - ${dataSyncAck.reason}`, hash, teamId) + this.recordLogSyncFailure(hash, `QSS rejected log entry ${hash}: ${dataSyncAck.reason ?? 'unknown error'}`) } else { this.logger.debug('Successful log sync to QSS') if (dataSyncAck.payload.syncSeq != null) { await this.handleObservedSyncSeq(teamId, dataSyncAck.payload.syncSeq, true, `sync-ack hash=${hash}`) } success = true + this.recordLogSyncSuccess(hash) this.qssClient.emit(QSSEvents.QSS_LOG_SYNCED, dataSyncMessage.payload!.teamId) } @@ -840,6 +868,27 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul return success } + public async waitForLogEntrySyncAck(hash: string, timeoutMs = 15_000): Promise { + const knownResult = this._recentLogSyncResults.get(hash) + if (knownResult?.success) { + return + } + if (knownResult?.error) { + throw knownResult.error + } + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.removeLogSyncWaiter(hash, timeout) + reject(new Error(`Timed out waiting for QSS to ack log entry ${hash}`)) + }, timeoutMs) + + const waiters = this._logSyncWaiters.get(hash) ?? [] + waiters.push({ resolve, reject, timeout }) + this._logSyncWaiters.set(hash, waiters) + }) + } + /** * Pull log entries from QSS for a given team. * @@ -1124,7 +1173,75 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul } this._logPullIntervals.clear() this._logPullInFlight.clear() + for (const [hash, waiters] of this._logSyncWaiters.entries()) { + for (const waiter of waiters) { + clearTimeout(waiter.timeout) + waiter.reject(new Error(`QSS service closed before log entry ${hash} was acknowledged`)) + } + } + this._logSyncWaiters.clear() + this._recentLogSyncResults.clear() this.qssAuthConnManager.close() this.qssClient.close() } + + private recordLogSyncSuccess(hash: string): void { + this.setRecentLogSyncResult(hash, { success: true }) + const waiters = this._logSyncWaiters.get(hash) + if (waiters == null) { + return + } + + this._logSyncWaiters.delete(hash) + for (const waiter of waiters) { + clearTimeout(waiter.timeout) + waiter.resolve() + } + } + + private recordLogSyncFailure(hash: string, message: string): void { + const error = new Error(message) + this.setRecentLogSyncResult(hash, { success: false, error }) + const waiters = this._logSyncWaiters.get(hash) + if (waiters == null) { + return + } + + this._logSyncWaiters.delete(hash) + for (const waiter of waiters) { + clearTimeout(waiter.timeout) + waiter.reject(error) + } + } + + private setRecentLogSyncResult(hash: string, result: { success: boolean; error?: Error }): void { + if (this._recentLogSyncResults.has(hash)) { + this._recentLogSyncResults.delete(hash) + } + this._recentLogSyncResults.set(hash, result) + + const maxTrackedResults = 200 + while (this._recentLogSyncResults.size > maxTrackedResults) { + const oldestHash = this._recentLogSyncResults.keys().next().value + if (oldestHash == null) { + break + } + this._recentLogSyncResults.delete(oldestHash) + } + } + + private removeLogSyncWaiter(hash: string, timeout: NodeJS.Timeout): void { + const waiters = this._logSyncWaiters.get(hash) + if (waiters == null) { + return + } + + const remainingWaiters = waiters.filter(waiter => waiter.timeout !== timeout) + if (remainingWaiters.length === 0) { + this._logSyncWaiters.delete(hash) + return + } + + this._logSyncWaiters.set(hash, remainingWaiters) + } } diff --git a/packages/backend/src/nest/storage/channels/channel.store.ts b/packages/backend/src/nest/storage/channels/channel.store.ts index 230ff90572..49e626334a 100644 --- a/packages/backend/src/nest/storage/channels/channel.store.ts +++ b/packages/backend/src/nest/storage/channels/channel.store.ts @@ -32,6 +32,10 @@ import { SigChainService } from '../../auth/sigchain.service' export class ChannelStore extends EventStoreBase { private channelData: PublicChannel private _subscribing: boolean = false + private authListenerAttached = false + private readonly handleAuthUpdated = (): void => { + void this.refreshMessageIds() + } private logger: QuietLogger @@ -115,9 +119,10 @@ export class ChannelStore extends EventStoreBase { - this.refreshMessageIds() - }) + if (!this.authListenerAttached) { + this.auth.on('updated', this.handleAuthUpdated) + this.authListenerAttached = true + } try { await this.startSync() @@ -344,12 +349,16 @@ export class ChannelStore extends EventStoreBase | undefined + private fileManagerEventsAttached = false + private sigchainListenerAttached = false + private readonly handleSigchainUpdated = async (): Promise => { + if (!this.channels) { + return + } + + try { + const currentChannelsCount = (await this.getChannels()).length + await this.channels.retryIndexingUnindexedEntries() + const newChannelsCount = (await this.getChannels()).length + if (currentChannelsCount !== newChannelsCount) { + await this.broadcastCurrentChannels() + } + } catch (e) { + this.logger.warn('Error when attempting to reindex on sigchain update', e) + } + } private readonly logger = createLogger(`storage:channels`) @@ -146,19 +164,10 @@ export class ChannelsService extends EventEmitter { this.broadcastCurrentChannels() }) - this.sigchainService.on('updated', async payload => { - try { - const currentChannelsCount = (await this.getChannels()).length - await this.channels!.retryIndexingUnindexedEntries() - const newChannelsCount = (await this.getChannels()).length - if (currentChannelsCount !== newChannelsCount) { - await this.broadcastCurrentChannels() - } - } catch (e) { - this.logger.warn('Error when attempting to reindex on sigchain update', e) - return - } - }) + if (!this.sigchainListenerAttached) { + this.sigchainService.on('updated', this.handleSigchainUpdated) + this.sigchainListenerAttached = true + } const channels = await this.getChannels() this.logger.info('Channels count:', channels.length) @@ -566,6 +575,10 @@ export class ChannelsService extends EventEmitter { * @emits StorageEvents.DOWNLOAD_PROGRESS */ private attachFileManagerEvents(): void { + if (this.fileManagerEventsAttached) { + return + } + this.filesManager.on(IpfsFilesManagerEvents.DOWNLOAD_PROGRESS, status => { this.emit(StorageEvents.DOWNLOAD_PROGRESS, status) }) @@ -584,6 +597,8 @@ export class ChannelsService extends EventEmitter { this.filesManager.on(StorageEvents.MESSAGE_MEDIA_UPDATED, payload => { this.emit(StorageEvents.MESSAGE_MEDIA_UPDATED, payload) }) + + this.fileManagerEventsAttached = true } /** diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts index f04529d45b..9a1d7b4b33 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts @@ -128,6 +128,15 @@ describe('NotificationTokensStore', () => { expect(result.tokens).toHaveLength(MAX_TOKENS_PER_USER) expect(result.tokens[0]).toBe('ucan-new') // ucan-0 evicted }) + + test('tombstoneUser writes an empty token list for the user', async () => { + await notificationTokensStore.addToken(userId, 'ucan-1') + + const hash = await notificationTokensStore.tombstoneUser(userId) + + expect(hash).toEqual(expect.any(String)) + await expect(notificationTokensStore.getEntry(userId)).resolves.toEqual({ userId, tokens: [] }) + }) }) describe('NotificationTokensStore/validateEntry', () => { diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts index 86431f77ef..21e797db6f 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.ts @@ -28,6 +28,7 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt private readonly auth: SigChainService ) { super() + this.auth.on('updated', this.handleAuthUpdated) } public async init() { @@ -49,15 +50,6 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt }) }) - this.auth.on('updated', async () => { - try { - await this.flushDeferredEntries() - await this.store!.retryIndexingUnindexedEntries() - } catch (err) { - logger.error('Failed to update notification tokens:', err) - } - }) - await this.store!.retryIndexingUnindexedEntries() this.emit(StorageEvents.NOTIFICATION_TOKENS_STORED, { @@ -65,6 +57,19 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt }) } + private readonly handleAuthUpdated = async (): Promise => { + if (!this.store) { + return + } + + try { + await this.flushDeferredEntries() + await this.store.retryIndexingUnindexedEntries() + } catch (err) { + logger.error('Failed to update notification tokens:', err) + } + } + public async startSync() { await this.getStore().sync.start() await this.flushDeferredEntries() @@ -141,6 +146,20 @@ export class NotificationTokensStore extends EncryptedKeyValueIndexedValidatedSt } } + public async tombstoneUser(userId: string): Promise { + const tombstoneEntry: PushNotificationTokens = { userId, tokens: [] } + + try { + const encEntry = await this.encryptEntry(tombstoneEntry) + const hash = await this.getStore().put(userId, encEntry) + return hash + } catch (err) { + logger.error('Failed to tombstone notification token entry:', userId, err) + this.deferredEntries.push(tombstoneEntry) + throw err + } + } + /** * Appends a UCAN token for a user, enforcing a max of MAX_TOKENS_PER_USER. * Deduplicates by exact string match. Evicts oldest tokens when at capacity. diff --git a/packages/backend/src/nest/storage/orbitDb/identity/lfa/lfa-identity.service.ts b/packages/backend/src/nest/storage/orbitDb/identity/lfa/lfa-identity.service.ts index 4462a3f330..7bb1a91402 100644 --- a/packages/backend/src/nest/storage/orbitDb/identity/lfa/lfa-identity.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/identity/lfa/lfa-identity.service.ts @@ -18,6 +18,22 @@ import { SerializerEncodingType } from '@quiet/types' @Injectable() class LFAIdentities extends EventEmitter { + private readonly lfaKeyStore: KeyStoreType = { + clear: async () => {}, + close: async () => {}, + hasKey: async () => false, + addKey: async () => { + throw new Error('OrbitDB keystore operations are unsupported for LFA identities') + }, + createKey: async () => { + throw new Error('OrbitDB keystore operations are unsupported for LFA identities') + }, + getKey: async () => undefined, + getPublic: () => { + throw new Error('OrbitDB keystore operations are unsupported for LFA identities') + }, + } + constructor( private readonly sigchainService: SigChainService, private readonly provider: LFAIdentityProvider, @@ -27,11 +43,12 @@ class LFAIdentities extends EventEmitter { } /** - * NOTE: this is intentionally returning an empty document as its just to support the original - * identity service type from OrbitDB and the returned value isn't used anywhere + * OrbitDB expects custom identity services to expose a keystore-like object and + * closes it during shutdown. LFA identities do not persist OrbitDB signing keys, + * so this is a minimal compatibility shim rather than a real key store. */ get keystore(): KeyStoreType { - return {} as any + return this.lfaKeyStore } /** diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts index 0e7100d0ce..f786bce0f9 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts @@ -89,6 +89,13 @@ describe('OrbitDbService', () => { expect(orbitDbService.identities).toBeDefined() }) + it('stops the orbitDb instance cleanly after create', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) + await expect(orbitDbService.stop()).resolves.toBeUndefined() + expect(() => orbitDbService.orbitDb).toThrowError('[get orbitDb]:no orbitDbInstance') + expect(orbitDbService.identities).toBeUndefined() + }) + it('does not throw an error when accessing orbitDb after creating instance', () => { expect(() => orbitDbService.orbitDb).not.toThrowError('[get orbitDb]:no orbitDbInstance') }) diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts index 33240cc466..e21265e6aa 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.ts @@ -262,8 +262,13 @@ export class OrbitDbService { const entries: LogEntry[] = [] const store = this.stores[address] for (const hash of hashes) { - const entry = await (store.log as LogType).get(hash) - entries.push(entry) + try { + const entry = await (store.log as LogType).get(hash) + entries.push(entry) + } catch (err) { + this.logger.warn(`Failed to get log entry ${hash} from store ${address}`, err) + continue + } } return entries diff --git a/packages/backend/src/nest/storage/storage.service.ts b/packages/backend/src/nest/storage/storage.service.ts index 5c4b19d068..a86cdeeefd 100644 --- a/packages/backend/src/nest/storage/storage.service.ts +++ b/packages/backend/src/nest/storage/storage.service.ts @@ -33,6 +33,7 @@ import path from 'path' export class StorageService extends EventEmitter { private initialized: boolean = false private initializing: boolean = false + private storeListenersAttached = false private readonly logger = createLogger(StorageService.name) @@ -240,12 +241,17 @@ export class StorageService extends EventEmitter { } public attachStoreListeners() { + if (this.storeListenersAttached) { + return + } + this.userProfileStore.on(StorageEvents.USER_PROFILES_STORED, (payload: UserProfilesStoredEvent) => { this.emit(StorageEvents.USER_PROFILES_STORED, payload) }) this.notificationTokensStore.on(StorageEvents.NOTIFICATION_TOKENS_STORED, payload => { this.emit(StorageEvents.NOTIFICATION_TOKENS_STORED, payload) }) + this.storeListenersAttached = true } public async addUserProfile(profile: UserProfile): Promise { diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index 07c5ea2593..da9e9d8425 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -21,12 +21,25 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase > { private deferredProfiles: UserProfile[] = [] private nicknameMaps: Map = new Map() + private readonly handleAuthUpdated = async (): Promise => { + if (!this.store) { + return + } + + try { + await this.flushDeferredEntries() + await this.store.retryIndexingUnindexedEntries() + } catch (err) { + logger.error('Failed to update user profiles:', err) + } + } constructor( private readonly orbitDbService: OrbitDbService, private readonly auth: SigChainService ) { super() + this.auth.on('updated', this.handleAuthUpdated) } public async init() { @@ -49,15 +62,6 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase }) }) - this.auth.on('updated', async payload => { - try { - await this.flushDeferredEntries() - await this.store!.retryIndexingUnindexedEntries() - } catch (err) { - logger.error('Failed to update user profiles:', err) - } - }) - await this.store!.retryIndexingUnindexedEntries() this.emit(StorageEvents.USER_PROFILES_STORED, { diff --git a/packages/mobile/ios/CommunicationBridge.m b/packages/mobile/ios/CommunicationBridge.m index 985581698f..4e2882fb23 100644 --- a/packages/mobile/ios/CommunicationBridge.m +++ b/packages/mobile/ios/CommunicationBridge.m @@ -11,4 +11,5 @@ @interface RCT_EXTERN_MODULE(CommunicationModule, RCTEventEmitter) RCT_EXTERN_METHOD(saveNseQssUrl:(NSString *)teamId qssUrl:(NSString *)qssUrl) RCT_EXTERN_METHOD(saveNseLastSyncSeq:(NSString *)teamId syncSeq:(nonnull NSNumber *)syncSeq) +RCT_EXTERN_METHOD(clearSensitiveData) @end diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 768cef4ecb..7f614c0427 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -18,6 +18,7 @@ class CommunicationModule: RCTEventEmitter { static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount" static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground" static let APP_GROUP_IDENTIFIER = "group.com.quietmobile" + static let INSTALLATION_SENTINEL_KEY = "quiet.installation.initialized" static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_" private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule") @@ -106,12 +107,20 @@ class CommunicationModule: RCTEventEmitter { } } + @objc + func clearSensitiveData() { + CommunicationModule.clearSensitiveDataImpl() + } + @objc func saveDeviceCredentials(_ deviceId: NSString, teamId: NSString, signingPrivateKey: NSString) { let deviceIdStr = deviceId as String let teamIdStr = teamId as String let keyStr = signingPrivateKey as String - let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String + guard let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String else { + CommunicationModule.logger.error("saveDeviceCredentials: missing QuietKeychainAccessGroup configuration") + return + } func writeItem(account: String, value: String) { guard let data = value.data(using: .utf8) else { @@ -122,10 +131,8 @@ class CommunicationModule: RCTEventEmitter { var deleteQuery: [CFString: Any] = [ kSecClass: kSecClassGenericPassword, kSecAttrAccount: account, + kSecAttrAccessGroup: accessGroup, ] - if let accessGroup { - deleteQuery[kSecAttrAccessGroup] = accessGroup - } SecItemDelete(deleteQuery as CFDictionary) var addQuery: [CFString: Any] = [ @@ -133,10 +140,8 @@ class CommunicationModule: RCTEventEmitter { kSecAttrAccount: account, kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, kSecValueData: data, + kSecAttrAccessGroup: accessGroup, ] - if let accessGroup { - addQuery[kSecAttrAccessGroup] = accessGroup - } let status = SecItemAdd(addQuery as CFDictionary, nil) if status != errSecSuccess { CommunicationModule.logger.error("saveDeviceCredentials: SecItemAdd failed for \(account): \(status)") @@ -186,7 +191,7 @@ class CommunicationModule: RCTEventEmitter { existing[teamIdStr] = qssUrlStr defaults.set(existing, forKey: CommunicationModule.NSE_QSS_URLS_KEY) - CommunicationModule.logger.info("saveNseQssUrl: stored for team \(teamIdStr, privacy: .public)") + CommunicationModule.logger.info("saveNseQssUrl: stored for team \(teamIdStr, privacy: .public) as \(qssUrlStr, privacy: .public)") } @objc @@ -208,7 +213,7 @@ class CommunicationModule: RCTEventEmitter { defaults.set(newSyncSeq, forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) defaults.set(teamIdStr, forKey: CommunicationModule.NSE_LAST_SYNC_TEAM_ID_KEY) - CommunicationModule.logger.info("saveNseLastSyncSeq: stored \(newSyncSeq, privacy: .public)") + CommunicationModule.logger.info("saveNseLastSyncSeq: stored \(newSyncSeq, privacy: .public) for team \(teamIdStr, privacy: .public)") } @objc @@ -267,4 +272,53 @@ class CommunicationModule: RCTEventEmitter { ] } + @objc + static func performFreshInstallCleanupIfNeeded() { + let defaults = UserDefaults.standard + if defaults.bool(forKey: CommunicationModule.INSTALLATION_SENTINEL_KEY) { + return + } + + CommunicationModule.logger.info("First launch detected, clearing persisted sensitive data") + CommunicationModule.clearSensitiveDataImpl() + defaults.set(true, forKey: CommunicationModule.INSTALLATION_SENTINEL_KEY) + } + + private static func clearSensitiveDataImpl() { + let keychainHandler = KeychainHandler() + let userMetadataHandler = UserMetadataHandler() + + do { + try keychainHandler.clearAllQuietData() + } catch { + CommunicationModule.logger.error("Failed clearing sensitive keychain data: \(error)") + } + + do { + try userMetadataHandler.clearAllUserMetadata() + } catch { + CommunicationModule.logger.error("Failed clearing user metadata: \(error)") + } + + let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard + defaults.removeObject(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) + defaults.removeObject(forKey: CommunicationModule.NSE_LAST_SYNC_TEAM_ID_KEY) + defaults.removeObject(forKey: CommunicationModule.NSE_QSS_URLS_KEY) + defaults.removeObject(forKey: CommunicationModule.NSE_BADGE_COUNT_KEY) + defaults.synchronize() + + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) { error in + if let error { + CommunicationModule.logger.error("clearSensitiveData: failed to clear badge count: \(error)") + } + } + } else { + DispatchQueue.main.async { + UIApplication.shared.applicationIconBadgeNumber = 0 + } + } + } + } diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift index f350fb8d28..2f6e67e575 100644 --- a/packages/mobile/ios/KeychainHandler.swift +++ b/packages/mobile/ios/KeychainHandler.swift @@ -7,6 +7,7 @@ public enum KeychainError: Error { case noPassword case unexpectedPasswordData case unexpectedItemData + case missingAccessGroupConfiguration case unhandledError(status: OSStatus) } @@ -17,6 +18,7 @@ public enum ConversionError: Error { public enum KeychainHandlerError: Error { case noKeyFound case malformedKey + case missingAccessGroupConfiguration case unhandledError(reason: Any) } @@ -40,22 +42,14 @@ class KeychainHandler: NSObject { public func getLfaKeyString(keyName: String) throws -> String { do { - let password: String = try _getKeyImpl(keyName: keyName, includeAccessGroup: true) + let password: String = try _getKeyImpl(keyName: keyName) return password } catch KeychainError.noPassword { - do { - let password = try _getKeyImpl(keyName: keyName, includeAccessGroup: false) - migrateLegacyKeyIfNeeded(keyName: keyName, value: password) - return password - } catch KeychainError.noPassword { - throw KeychainHandlerError.noKeyFound - } catch KeychainError.unexpectedPasswordData { - throw KeychainHandlerError.malformedKey - } catch { - throw KeychainHandlerError.unhandledError(reason: error) - } + throw KeychainHandlerError.noKeyFound } catch KeychainError.unexpectedPasswordData { throw KeychainHandlerError.malformedKey + } catch KeychainError.missingAccessGroupConfiguration { + throw KeychainHandlerError.missingAccessGroupConfiguration } catch ConversionError.stringToBytesError { throw KeychainHandlerError.malformedKey } catch { @@ -64,33 +58,44 @@ class KeychainHandler: NSObject { } public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus { - if let sharedKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: true) { + if let sharedKey = try? _getKeyImpl(keyName: namedKey.keyName) { guard sharedKey == namedKey.key else { return KeyAddStatus.duplicateScope } return KeyAddStatus.success } - if let legacyKey = try? _getKeyImpl(keyName: namedKey.keyName, includeAccessGroup: false) { - guard legacyKey == namedKey.key else { - return KeyAddStatus.duplicateScope - } - } - do { let keyData: Data = try _stringToBytes(str: namedKey.key) let addStatus: KeyAddStatus = try _addKeyToKeychainImpl( keyName: namedKey.keyName, - keyData: keyData, - includeAccessGroup: true + keyData: keyData ) return addStatus + } catch KeychainError.missingAccessGroupConfiguration { + throw KeychainHandlerError.missingAccessGroupConfiguration } catch { throw KeychainHandlerError.unhandledError(reason: error) } } - private func _getKeyImpl(keyName: String, includeAccessGroup: Bool) throws -> String { + public func clearAllQuietData() throws { + KeychainHandler.logger.info("clearAllQuietData: starting keychain cleanup") + try deleteLfaKeys(matchingPrefix: "quiet_") + try deleteGenericPasswordAccounts(matchingPrefix: "quiet.device.privateKey.", service: nil) + try deleteGenericPasswordAccount(account: "quiet.device.id", service: nil) + try deleteGenericPasswordAccount(account: "quiet.team.id", service: nil) + KeychainHandler.logger.info("clearAllQuietData: finished keychain cleanup") + } + + private func requiredAccessGroup() throws -> String { + guard let accessGroup else { + throw KeychainError.missingAccessGroupConfiguration + } + return accessGroup + } + + private func _getKeyImpl(keyName: String) throws -> String { var existingKey: CFTypeRef? var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -100,9 +105,7 @@ class KeychainHandler: NSObject { kSecReturnAttributes as String: true, kSecReturnData as String: true ] - if includeAccessGroup, let accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } + query[kSecAttrAccessGroup as String] = try requiredAccessGroup() let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey) guard status != errSecItemNotFound else { throw KeychainError.noPassword } guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } @@ -115,7 +118,7 @@ class KeychainHandler: NSObject { return password } - private func _addKeyToKeychainImpl(keyName: String, keyData: Data, includeAccessGroup: Bool) throws -> KeyAddStatus { + private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: keyName, @@ -123,9 +126,7 @@ class KeychainHandler: NSObject { kSecValueData as String: keyData, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock ] - if includeAccessGroup, let accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } + query[kSecAttrAccessGroup as String] = try requiredAccessGroup() let status: OSStatus = SecItemAdd(query as CFDictionary, nil) if status == errSecSuccess { @@ -143,31 +144,107 @@ class KeychainHandler: NSObject { return bytes! } - private func _deleteKeyImpl(keyName: String, includeAccessGroup: Bool) throws { + private func _deleteKeyImpl(keyName: String) throws { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: keychainService, kSecAttrAccount as String: keyName, ] - if includeAccessGroup, let accessGroup { - query[kSecAttrAccessGroup as String] = accessGroup - } + query[kSecAttrAccessGroup as String] = try requiredAccessGroup() let status = SecItemDelete(query as CFDictionary) + logDeletionStatus(account: keyName, service: keychainService, status: status) if status != errSecSuccess && status != errSecItemNotFound { throw KeychainError.unhandledError(status: status) } } - private func migrateLegacyKeyIfNeeded(keyName: String, value: String) { - guard let data = value.data(using: .utf8) else { - return + private func listGenericPasswordAccounts(service: String?) throws -> [String] { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll, + ] + if let service { + query[kSecAttrService as String] = service } + query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - do { - _ = try _addKeyToKeychainImpl(keyName: keyName, keyData: data, includeAccessGroup: true) - } catch { - KeychainHandler.logger.error("Failed to migrate legacy key \(keyName) into shared access group: \(error.localizedDescription)") + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { + return [] + } + guard status == errSecSuccess else { + throw KeychainError.unhandledError(status: status) + } + + let items: [[String: Any]] + if let item = result as? [String: Any] { + items = [item] + } else if let manyItems = result as? [[String: Any]] { + items = manyItems + } else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + private func deleteGenericPasswordAccount(account: String, service: String?) throws { + try _deleteGenericPasswordAccount(account: account, service: service) + } + + private func deleteGenericPasswordAccounts(matchingPrefix prefix: String, service: String?) throws { + let accounts = try listGenericPasswordAccounts(service: service) + + for account in accounts where account.hasPrefix(prefix) { + try deleteGenericPasswordAccount(account: account, service: service) + } + } + + private func deleteLfaKeys(matchingPrefix prefix: String) throws { + let keys = try listGenericPasswordAccounts(service: keychainService) + + for keyName in keys where keyName.hasPrefix(prefix) { + try _deleteKeyImpl(keyName: keyName) + } + } + + private func _deleteGenericPasswordAccount(account: String, service: String?) throws { + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + ] + if let service { + query[kSecAttrService as String] = service + } + query[kSecAttrAccessGroup as String] = try requiredAccessGroup() + + let status = SecItemDelete(query as CFDictionary) + logDeletionStatus(account: account, service: service, status: status) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainError.unhandledError(status: status) + } + } + + private func logDeletionStatus(account: String, service: String?, status: OSStatus) { + let serviceLabel = service ?? "" + let scopeLabel = "with-access-group" + + switch status { + case errSecSuccess: + KeychainHandler.logger.info( + "Deleted keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public)" + ) + case errSecItemNotFound: + KeychainHandler.logger.debug( + "Keychain item not found during delete account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public)" + ) + default: + KeychainHandler.logger.error( + "Failed to delete keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public) status=\(status, privacy: .public)" + ) } } } diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 6c985e429b..1d62a2cb65 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -670,13 +670,6 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - EB611A772F60ADD1000CA1A2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - KeychainHelper.swift, - ); - target = 13B07F861A680F5B00A75B9A /* Quiet */; - }; EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -687,7 +680,7 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB611A772F60ADD1000CA1A2 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = QuietNotificationServiceExtension; sourceTree = ""; }; + EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = QuietNotificationServiceExtension; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ diff --git a/packages/mobile/ios/Quiet/AppDelegate+Firebase.swift b/packages/mobile/ios/Quiet/AppDelegate+Firebase.swift index da8542bb3e..eb9733b763 100644 --- a/packages/mobile/ios/Quiet/AppDelegate+Firebase.swift +++ b/packages/mobile/ios/Quiet/AppDelegate+Firebase.swift @@ -49,21 +49,6 @@ extension AppDelegate: MessagingDelegate { } } - // MARK: - Encryption Key Management - - @objc func storeEncryptionKey(_ key: String) { - guard let keyData = key.data(using: .utf8) else { - print("Error: Invalid encryption key format") - return - } - KeychainHelper.shared.save( - service: "com.quiet.notifications", - account: "encryptionKey", - data: keyData - ) - print("Encryption key stored successfully") - } - // MARK: - UNUserNotificationCenterDelegate // Handle notification when app is in foreground diff --git a/packages/mobile/ios/Quiet/AppDelegate.m b/packages/mobile/ios/Quiet/AppDelegate.m index 0893562d27..3df7ec57fa 100644 --- a/packages/mobile/ios/Quiet/AppDelegate.m +++ b/packages/mobile/ios/Quiet/AppDelegate.m @@ -53,6 +53,8 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // Configure Firebase [self configureFirebase]; + [CommunicationModule performFreshInstallCleanupIfNeeded]; + // Call only once per nodejs thread [self createDataDirectory]; diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m index d365441814..a3d487a7ab 100644 --- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.m +++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.m @@ -16,6 +16,4 @@ @interface RCT_EXTERN_MODULE(FirebaseMessagingModule, NSObject) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(setEncryptionKey:(NSString *)key) - @end diff --git a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift index adcaeb6174..f2e25e3cf4 100644 --- a/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift +++ b/packages/mobile/ios/Quiet/FirebaseMessagingModule.swift @@ -54,8 +54,4 @@ class FirebaseMessagingModule: NSObject { } } } - - @objc - func setEncryptionKey(_ key: String) { - } } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/KeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/KeychainHelper.swift deleted file mode 100644 index f626ca1ec2..0000000000 --- a/packages/mobile/ios/QuietNotificationServiceExtension/KeychainHelper.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import Security - -/// Helper class for storing and retrieving sensitive data in the Keychain -/// This is shared between the main app and the Notification Service Extension via App Groups -class KeychainHelper { - static let shared = KeychainHelper() - - private init() {} - - /// Read data from Keychain - /// - Parameters: - /// - service: The service identifier - /// - account: The account identifier - /// - Returns: The stored data, or nil if not found - func read(service: String, account: String) -> Data? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecAttrAccessGroup as String: "group.com.quiet.app" // App Group for sharing - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { - if status != errSecItemNotFound { - print("⚠️ Keychain read error: \(status)") - } - return nil - } - - return result as? Data - } - - /// Save data to Keychain - /// - Parameters: - /// - service: The service identifier - /// - account: The account identifier - /// - data: The data to store - func save(service: String, account: String, data: Data) { - // Delete existing item first - delete(service: service, account: account) - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - kSecValueData as String: data - ] - - let status = SecItemAdd(query as CFDictionary, nil) - - if status != errSecSuccess { - print("⚠️ Keychain save error: \(status)") - } else { - print("✅ Keychain item saved successfully") - } - } - - /// Delete data from Keychain - /// - Parameters: - /// - service: The service identifier - /// - account: The account identifier - func delete(service: String, account: String) { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - - let status = SecItemDelete(query as CFDictionary) - - if status != errSecSuccess && status != errSecItemNotFound { - print("⚠️ Keychain delete error: \(status)") - } - } -} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift index a7a69c286c..745d15b51b 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift @@ -81,6 +81,13 @@ struct NSEKeychainHelper { // Must match the shared keychain entitlement in both the main app and NSE targets. private static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String + private static func requiredAccessGroup() throws -> String { + guard let accessGroup else { + throw NSEAuthError.keychainError("Missing QuietKeychainAccessGroup configuration") + } + return accessGroup + } + private static func readData(account: String, label: String, service: String? = nil) throws -> Data { // Note: kSecAttrAccessible is intentionally omitted — it's a write attribute. // Including it in a read query can cause silent failures on some iOS versions. @@ -91,9 +98,7 @@ struct NSEKeychainHelper { kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne, ] - if let accessGroup { - query[kSecAttrAccessGroup] = accessGroup - } + query[kSecAttrAccessGroup] = try requiredAccessGroup() if let service { query[kSecAttrService] = service } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 785d016490..e305958281 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -72,12 +72,8 @@ class NotificationService: UNNotificationServiceExtension { log: nseLog, type: .error, teamId) return } - let qssUrlString = qssUrl.absoluteString - if let payloadQssUrl = userInfo["qssUrl"] as? String, payloadQssUrl != qssUrlString { - os_log("fetchAndUpdate: ignoring push payload qssUrl for teamId=%{public}@; using stored value", - log: nseLog, type: .info, teamId) - } + os_log("fetchAndUpdate: teamId=%{public}@ qssUrl=%{public}@", log: nseLog, type: .info, teamId, qssUrlString) diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift index 0f8141858d..ae52be27db 100644 --- a/packages/mobile/ios/UserMetadataHandler.swift +++ b/packages/mobile/ios/UserMetadataHandler.swift @@ -192,4 +192,27 @@ class UserMetadataHandler: NSObject { throw UserMetadataError.unhandledError(reason: error) } } + + public func clearAllUserMetadata() throws -> Void { + do { + try self.initContainer() + } catch { + throw error + } + + guard let context = self.modelContext else { + throw UserMetadataError.missingModelContext + } + + do { + let models = try context.fetch(FetchDescriptor()) + for model in models { + context.delete(model) + } + try context.save() + } catch { + UserMetadataHandler.logger.error("Error while clearing UserMetadata: \(error)") + throw UserMetadataError.unhandledError(reason: error) + } + } } diff --git a/packages/mobile/src/setupTests.tsx b/packages/mobile/src/setupTests.tsx index 73675155bd..8e2f672df7 100644 --- a/packages/mobile/src/setupTests.tsx +++ b/packages/mobile/src/setupTests.tsx @@ -52,6 +52,7 @@ jest.mock('react-native', () => { saveUserMetadata: jest.fn(), saveNseQssUrl: jest.fn(), saveNseLastSyncSeq: jest.fn(), + clearSensitiveData: jest.fn(), } rn.NativeModules.FirebaseMessagingModule = { getToken: jest.fn(), diff --git a/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.test.ts b/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.test.ts new file mode 100644 index 0000000000..b7908140fb --- /dev/null +++ b/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.test.ts @@ -0,0 +1,38 @@ +import { NativeModules } from 'react-native' +import { expectSaga } from 'redux-saga-test-plan' +import { call } from 'redux-saga-test-plan/matchers' +import { app } from '@quiet/state-manager' + +import { leaveCommunitySaga } from './leaveCommunity.saga' + +describe('leaveCommunitySaga', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('closes backend services and clears native sensitive data', async () => { + await expectSaga(leaveCommunitySaga) + .provide([[call.fn(NativeModules.CommunicationModule.clearSensitiveData), null]]) + .put.like({ + action: { + type: app.actions.closeServices.type, + }, + }) + .call.fn(NativeModules.CommunicationModule.clearSensitiveData) + .run() + }) + + it('still leaves the community when native cleanup throws', async () => { + jest.spyOn(NativeModules.CommunicationModule, 'clearSensitiveData').mockImplementation(() => { + throw new Error('cleanup failed') + }) + + await expectSaga(leaveCommunitySaga) + .put.like({ + action: { + type: app.actions.closeServices.type, + }, + }) + .run() + }) +}) diff --git a/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.ts b/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.ts index 54063bd5f5..803744a558 100644 --- a/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.ts +++ b/packages/mobile/src/store/nativeServices/leaveCommunity/leaveCommunity.saga.ts @@ -1,5 +1,6 @@ import { select, call, putResolve } from 'typed-redux-saga' import { app } from '@quiet/state-manager' +import { NativeModules } from 'react-native' import { persistor } from '../../store' import { nativeServicesActions } from '../nativeServices.slice' import { initActions } from '../../init/init.slice' @@ -14,6 +15,15 @@ export function* leaveCommunitySaga(): Generator { logger.info('Leaving community') // Restart backend yield* putResolve(app.actions.closeServices()) + + const clearSensitiveData = NativeModules.CommunicationModule?.clearSensitiveData + if (clearSensitiveData) { + try { + yield* call(clearSensitiveData) + } catch (error) { + logger.error('Failed to clear native sensitive data while leaving community', error) + } + } } export function* clearReduxStore(): Generator { diff --git a/packages/state-manager/src/sagas/users/users.slice.ts b/packages/state-manager/src/sagas/users/users.slice.ts index 54064031b7..ce8432015f 100644 --- a/packages/state-manager/src/sagas/users/users.slice.ts +++ b/packages/state-manager/src/sagas/users/users.slice.ts @@ -31,10 +31,7 @@ export const usersSlice = createSlice({ }, // Bootstraps initial user profiles from the server, wipes state and sets new profiles setUserProfiles: (state, action: PayloadAction) => { - // Creating user profiles object for backwards compatibility with 2.0.1 - if (!state.userProfiles) { - state.userProfiles = {} - } + state.userProfiles = {} for (const userProfile of action.payload) { state.userProfiles[userProfile.userId] = userProfile } From 5b5336d4b28620678616ae3ff783b293afd979c0 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 6 Apr 2026 16:38:19 -0400 Subject: [PATCH 48/92] dedupe keychain handling; breakout shared services; --- packages/mobile/ios/CommunicationModule.swift | 102 ++----- packages/mobile/ios/KeychainHandler.swift | 250 ----------------- .../ios/Quiet.xcodeproj/project.pbxproj | 25 +- .../NSEAuthService.swift | 64 +---- .../NSECryptoService.swift | 2 +- .../NSEKeychainHelper.swift | 121 --------- .../NotificationService.swift | 16 +- packages/mobile/ios/Shared/Base58.swift | 62 +++++ .../mobile/ios/Shared/KeychainService.swift | 257 ++++++++++++++++++ .../mobile/ios/Shared/SharedDefaults.swift | 86 ++++++ packages/mobile/ios/UserMetadataHandler.swift | 2 +- 11 files changed, 453 insertions(+), 534 deletions(-) delete mode 100644 packages/mobile/ios/KeychainHandler.swift delete mode 100644 packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift create mode 100644 packages/mobile/ios/Shared/Base58.swift create mode 100644 packages/mobile/ios/Shared/KeychainService.swift create mode 100644 packages/mobile/ios/Shared/SharedDefaults.swift diff --git a/packages/mobile/ios/CommunicationModule.swift b/packages/mobile/ios/CommunicationModule.swift index 7f614c0427..c37f3c01c2 100644 --- a/packages/mobile/ios/CommunicationModule.swift +++ b/packages/mobile/ios/CommunicationModule.swift @@ -12,18 +12,11 @@ class CommunicationModule: RCTEventEmitter { static let APP_RESUME_IDENTIFIER = "appresume" static let NOTIFICATION_PERMISSION_RESULT = "notificationPermissionResult" static let DEVICE_TOKEN_RECEIVED = "deviceTokenReceived" - static let NSE_LAST_SYNC_SEQ_KEY = "quiet.nse.lastSyncSeq" - static let NSE_LAST_SYNC_TEAM_ID_KEY = "quiet.nse.lastSyncTeamId" - static let NSE_QSS_URLS_KEY = "quiet.nse.qssUrls" - static let NSE_BADGE_COUNT_KEY = "quiet.nse.badgeCount" - static let APP_IS_FOREGROUND_KEY = "quiet.app.isForeground" - static let APP_GROUP_IDENTIFIER = "group.com.quietmobile" static let INSTALLATION_SENTINEL_KEY = "quiet.installation.initialized" static let WEBSOCKET_CONNECTION_CHANNEL = "_WEBSOCKET_CONNECTION_" private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CommunicationModule") - let keychainHandler = KeychainHandler() let userMetadataHandler = UserMetadataHandler() private var hasListeners = false @@ -40,16 +33,14 @@ class CommunicationModule: RCTEventEmitter { @objc func appPause() { - let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - defaults.set(false, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY) + SharedDefaults.setAppForeground(false) self.sendEvent(withName: CommunicationModule.APP_PAUSE_IDENTIFIER, body: nil) } @objc func appResume() { - let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - defaults.set(true, forKey: CommunicationModule.APP_IS_FOREGROUND_KEY) - defaults.set(0, forKey: CommunicationModule.NSE_BADGE_COUNT_KEY) + SharedDefaults.setAppForeground(true) + SharedDefaults.setBadgeCount(0) UNUserNotificationCenter.current().removeAllDeliveredNotifications() if #available(iOS 17.0, *) { UNUserNotificationCenter.current().setBadgeCount(0) { error in @@ -97,8 +88,8 @@ class CommunicationModule: RCTEventEmitter { } let data = Data(keyAsString.utf8) let decodedNamedKey = try decoder.decode(NamedKey.self, from: data) - _ = try self.keychainHandler.addLfaKey(namedKey: decodedNamedKey) - let stored = try self.keychainHandler.getLfaKeyString(keyName: decodedNamedKey.keyName) + _ = try KeychainService.addLfaKey(keyName: decodedNamedKey.keyName, key: decodedNamedKey.key) + let stored = try KeychainService.getLfaKeyString(keyName: decodedNamedKey.keyName) CommunicationModule.logger.info("Stored key matches? \(stored == decodedNamedKey.key) \(decodedNamedKey.keyName)") } catch { // TODO: send a message to the backend with any keys that weren't stored @@ -114,45 +105,16 @@ class CommunicationModule: RCTEventEmitter { @objc func saveDeviceCredentials(_ deviceId: NSString, teamId: NSString, signingPrivateKey: NSString) { - let deviceIdStr = deviceId as String - let teamIdStr = teamId as String - let keyStr = signingPrivateKey as String - guard let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String else { - CommunicationModule.logger.error("saveDeviceCredentials: missing QuietKeychainAccessGroup configuration") - return - } - - func writeItem(account: String, value: String) { - guard let data = value.data(using: .utf8) else { - CommunicationModule.logger.error("saveDeviceCredentials: failed to encode \(account) as UTF-8") - return - } - // Delete any existing item first to allow updates - var deleteQuery: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrAccessGroup: accessGroup, - ] - SecItemDelete(deleteQuery as CFDictionary) - - var addQuery: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, - kSecValueData: data, - kSecAttrAccessGroup: accessGroup, - ] - let status = SecItemAdd(addQuery as CFDictionary, nil) - if status != errSecSuccess { - CommunicationModule.logger.error("saveDeviceCredentials: SecItemAdd failed for \(account): \(status)") - } else { - CommunicationModule.logger.info("saveDeviceCredentials: stored \(account)") - } + do { + try KeychainService.saveDeviceCredentials( + deviceId: deviceId as String, + teamId: teamId as String, + signingPrivateKey: signingPrivateKey as String + ) + CommunicationModule.logger.info("saveDeviceCredentials: stored successfully") + } catch { + CommunicationModule.logger.error("saveDeviceCredentials: \(error)") } - - writeItem(account: "quiet.device.id", value: deviceIdStr) - writeItem(account: "quiet.team.id", value: teamIdStr) - writeItem(account: "quiet.device.privateKey.\(deviceIdStr)", value: keyStr) } @objc @@ -181,16 +143,7 @@ class CommunicationModule: RCTEventEmitter { func saveNseQssUrl(_ teamId: NSString, qssUrl: NSString) { let teamIdStr = teamId as String let qssUrlStr = qssUrl as String - let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - var existing = defaults.dictionary(forKey: CommunicationModule.NSE_QSS_URLS_KEY) as? [String: String] ?? [:] - - if existing[teamIdStr] == qssUrlStr { - CommunicationModule.logger.debug("saveNseQssUrl: unchanged for team \(teamIdStr, privacy: .public)") - return - } - - existing[teamIdStr] = qssUrlStr - defaults.set(existing, forKey: CommunicationModule.NSE_QSS_URLS_KEY) + SharedDefaults.saveQssUrl(teamId: teamIdStr, url: qssUrlStr) CommunicationModule.logger.info("saveNseQssUrl: stored for team \(teamIdStr, privacy: .public) as \(qssUrlStr, privacy: .public)") } @@ -199,20 +152,9 @@ class CommunicationModule: RCTEventEmitter { _ teamId: NSString, syncSeq: NSNumber ) { - let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - let newSyncSeq = syncSeq.intValue - let existingSyncSeq = defaults.integer(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) let teamIdStr = teamId as String - - if existingSyncSeq >= newSyncSeq { - CommunicationModule.logger.debug( - "saveNseLastSyncSeq: ignoring stale seq \(newSyncSeq, privacy: .public), existing=\(existingSyncSeq, privacy: .public)" - ) - return - } - - defaults.set(newSyncSeq, forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) - defaults.set(teamIdStr, forKey: CommunicationModule.NSE_LAST_SYNC_TEAM_ID_KEY) + let newSyncSeq = syncSeq.int64Value + SharedDefaults.saveLastSyncSeqAndTeam(newSyncSeq, teamId: teamIdStr) CommunicationModule.logger.info("saveNseLastSyncSeq: stored \(newSyncSeq, privacy: .public) for team \(teamIdStr, privacy: .public)") } @@ -285,11 +227,10 @@ class CommunicationModule: RCTEventEmitter { } private static func clearSensitiveDataImpl() { - let keychainHandler = KeychainHandler() let userMetadataHandler = UserMetadataHandler() do { - try keychainHandler.clearAllQuietData() + try KeychainService.clearAllQuietData() } catch { CommunicationModule.logger.error("Failed clearing sensitive keychain data: \(error)") } @@ -300,12 +241,7 @@ class CommunicationModule: RCTEventEmitter { CommunicationModule.logger.error("Failed clearing user metadata: \(error)") } - let defaults = UserDefaults(suiteName: CommunicationModule.APP_GROUP_IDENTIFIER) ?? UserDefaults.standard - defaults.removeObject(forKey: CommunicationModule.NSE_LAST_SYNC_SEQ_KEY) - defaults.removeObject(forKey: CommunicationModule.NSE_LAST_SYNC_TEAM_ID_KEY) - defaults.removeObject(forKey: CommunicationModule.NSE_QSS_URLS_KEY) - defaults.removeObject(forKey: CommunicationModule.NSE_BADGE_COUNT_KEY) - defaults.synchronize() + SharedDefaults.clearAll() UNUserNotificationCenter.current().removeAllDeliveredNotifications() if #available(iOS 17.0, *) { diff --git a/packages/mobile/ios/KeychainHandler.swift b/packages/mobile/ios/KeychainHandler.swift deleted file mode 100644 index 2f6e67e575..0000000000 --- a/packages/mobile/ios/KeychainHandler.swift +++ /dev/null @@ -1,250 +0,0 @@ -import Foundation -import CryptoKit -import Security -import OSLog - -public enum KeychainError: Error { - case noPassword - case unexpectedPasswordData - case unexpectedItemData - case missingAccessGroupConfiguration - case unhandledError(status: OSStatus) -} - -public enum ConversionError: Error { - case stringToBytesError -} - -public enum KeychainHandlerError: Error { - case noKeyFound - case malformedKey - case missingAccessGroupConfiguration - case unhandledError(reason: Any) -} - -public enum KeyAddStatus { - case success - case duplicateScope -} - -public struct NamedKey: Codable { - let keyName: String - let key: String -} - -// TODO: add string to key object conversion (e.g. string to SymmetricKey) -@objc(KeychainHandler) -class KeychainHandler: NSObject { - private let keychainService: String = "com.quietmobile" - private lazy var accessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String - - private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "KeychainHandler") - - public func getLfaKeyString(keyName: String) throws -> String { - do { - let password: String = try _getKeyImpl(keyName: keyName) - return password - } catch KeychainError.noPassword { - throw KeychainHandlerError.noKeyFound - } catch KeychainError.unexpectedPasswordData { - throw KeychainHandlerError.malformedKey - } catch KeychainError.missingAccessGroupConfiguration { - throw KeychainHandlerError.missingAccessGroupConfiguration - } catch ConversionError.stringToBytesError { - throw KeychainHandlerError.malformedKey - } catch { - throw KeychainHandlerError.unhandledError(reason: error) - } - } - - public func addLfaKey(namedKey: NamedKey) throws -> KeyAddStatus { - if let sharedKey = try? _getKeyImpl(keyName: namedKey.keyName) { - guard sharedKey == namedKey.key else { - return KeyAddStatus.duplicateScope - } - return KeyAddStatus.success - } - - do { - let keyData: Data = try _stringToBytes(str: namedKey.key) - let addStatus: KeyAddStatus = try _addKeyToKeychainImpl( - keyName: namedKey.keyName, - keyData: keyData - ) - return addStatus - } catch KeychainError.missingAccessGroupConfiguration { - throw KeychainHandlerError.missingAccessGroupConfiguration - } catch { - throw KeychainHandlerError.unhandledError(reason: error) - } - } - - public func clearAllQuietData() throws { - KeychainHandler.logger.info("clearAllQuietData: starting keychain cleanup") - try deleteLfaKeys(matchingPrefix: "quiet_") - try deleteGenericPasswordAccounts(matchingPrefix: "quiet.device.privateKey.", service: nil) - try deleteGenericPasswordAccount(account: "quiet.device.id", service: nil) - try deleteGenericPasswordAccount(account: "quiet.team.id", service: nil) - KeychainHandler.logger.info("clearAllQuietData: finished keychain cleanup") - } - - private func requiredAccessGroup() throws -> String { - guard let accessGroup else { - throw KeychainError.missingAccessGroupConfiguration - } - return accessGroup - } - - private func _getKeyImpl(keyName: String) throws -> String { - var existingKey: CFTypeRef? - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keyName, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecReturnAttributes as String: true, - kSecReturnData as String: true - ] - query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - let status: OSStatus = SecItemCopyMatching(query as CFDictionary, &existingKey) - guard status != errSecItemNotFound else { throw KeychainError.noPassword } - guard status == errSecSuccess else { throw KeychainError.unhandledError(status: status) } - guard let existingItem: [String : Any] = existingKey as? [String : Any], - let passwordData = existingItem[kSecValueData as String] as? Data, - let password = String(data: passwordData, encoding: String.Encoding.utf8) - else { - throw KeychainError.unexpectedPasswordData - } - return password - } - - private func _addKeyToKeychainImpl(keyName: String, keyData: Data) throws -> KeyAddStatus { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: keyName, - kSecAttrService as String: keychainService, - kSecValueData as String: keyData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock - ] - query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - - let status: OSStatus = SecItemAdd(query as CFDictionary, nil) - if status == errSecSuccess { - return KeyAddStatus.success - } else if status == errSecDuplicateItem { - return KeyAddStatus.duplicateScope - } else { - throw KeychainError.unhandledError(status: status) - } - } - - private func _stringToBytes(str: String) throws -> Data { - let bytes: Data? = str.data(using: .utf8) - guard bytes != nil else { throw ConversionError.stringToBytesError } - return bytes! - } - - private func _deleteKeyImpl(keyName: String) throws { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: keychainService, - kSecAttrAccount as String: keyName, - ] - query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - - let status = SecItemDelete(query as CFDictionary) - logDeletionStatus(account: keyName, service: keychainService, status: status) - if status != errSecSuccess && status != errSecItemNotFound { - throw KeychainError.unhandledError(status: status) - } - } - - private func listGenericPasswordAccounts(service: String?) throws -> [String] { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitAll, - ] - if let service { - query[kSecAttrService as String] = service - } - query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { - return [] - } - guard status == errSecSuccess else { - throw KeychainError.unhandledError(status: status) - } - - let items: [[String: Any]] - if let item = result as? [String: Any] { - items = [item] - } else if let manyItems = result as? [[String: Any]] { - items = manyItems - } else { - return [] - } - - return items.compactMap { $0[kSecAttrAccount as String] as? String } - } - - private func deleteGenericPasswordAccount(account: String, service: String?) throws { - try _deleteGenericPasswordAccount(account: account, service: service) - } - - private func deleteGenericPasswordAccounts(matchingPrefix prefix: String, service: String?) throws { - let accounts = try listGenericPasswordAccounts(service: service) - - for account in accounts where account.hasPrefix(prefix) { - try deleteGenericPasswordAccount(account: account, service: service) - } - } - - private func deleteLfaKeys(matchingPrefix prefix: String) throws { - let keys = try listGenericPasswordAccounts(service: keychainService) - - for keyName in keys where keyName.hasPrefix(prefix) { - try _deleteKeyImpl(keyName: keyName) - } - } - - private func _deleteGenericPasswordAccount(account: String, service: String?) throws { - var query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - ] - if let service { - query[kSecAttrService as String] = service - } - query[kSecAttrAccessGroup as String] = try requiredAccessGroup() - - let status = SecItemDelete(query as CFDictionary) - logDeletionStatus(account: account, service: service, status: status) - if status != errSecSuccess && status != errSecItemNotFound { - throw KeychainError.unhandledError(status: status) - } - } - - private func logDeletionStatus(account: String, service: String?, status: OSStatus) { - let serviceLabel = service ?? "" - let scopeLabel = "with-access-group" - - switch status { - case errSecSuccess: - KeychainHandler.logger.info( - "Deleted keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public)" - ) - case errSecItemNotFound: - KeychainHandler.logger.debug( - "Keychain item not found during delete account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public)" - ) - default: - KeychainHandler.logger.error( - "Failed to delete keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) scope=\(scopeLabel, privacy: .public) status=\(status, privacy: .public)" - ) - } - } -} diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index 1d62a2cb65..e7a8bb7bbf 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -57,7 +57,6 @@ 18FD2A40296F009E00A2B8C0 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 18FD2A39296F009E00A2B8C0 /* main.m */; }; 2815B731ADE0FE0C770C78E3 /* Pods_QuietNotificationServiceExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1725031A98BBEE5E8F3F6561 /* Pods_QuietNotificationServiceExtension.framework */; }; 663DC8C12F621139005D2086 /* UserMetadataHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */; }; - 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */; }; 673478D924128C1639690CFF /* Pods_Quiet_QuietTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */; }; 955DC7582BD930B30014725B /* WebsocketSingleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 955DC7572BD930B30014725B /* WebsocketSingleton.swift */; }; CE99A25A0E4E1440E0C42536 /* Pods_Quiet.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */; }; @@ -648,7 +647,6 @@ 1A49D8557AFF7D73033A5FD2 /* Pods_Quiet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 58FBDCDCEEA5EACED0FF5D68 /* Pods-Quiet-QuietTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.release.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.release.xcconfig"; sourceTree = ""; }; 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserMetadataHandler.swift; sourceTree = ""; }; - 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHandler.swift; sourceTree = ""; }; 72EA7AA4B77C0780A86EC300 /* Pods-Quiet-QuietTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Quiet-QuietTests.debug.xcconfig"; path = "Target Support Files/Pods-Quiet-QuietTests/Pods-Quiet-QuietTests.debug.xcconfig"; sourceTree = ""; }; 805522FA9F468CE465C44459 /* Pods_Quiet_QuietTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Quiet_QuietTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 84F12DFE2A5B0E05C2C41286 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Quiet/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -670,7 +668,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( Info.plist, @@ -680,7 +678,19 @@ /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ - EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EBE628BC2F60AFB00062530D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = QuietNotificationServiceExtension; sourceTree = ""; }; + AA0000012F70000000000001 /* Shared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Shared; + sourceTree = ""; + }; + EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EBE628BC2F60AFB00062530D /* Exceptions for "QuietNotificationServiceExtension" folder in "QuietNotificationServiceExtension" target */, + ); + path = QuietNotificationServiceExtension; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -735,7 +745,6 @@ isa = PBXGroup; children = ( 663DC8C02F621134005D2086 /* UserMetadataHandler.swift */, - 665587C92F4F5ECD005D2086 /* KeychainHandler.swift */, EB4DC8152F60912900EFD23F /* AppDelegate+Firebase.swift */, 180E120A2AEFB7F900804659 /* Utils.swift */, 18FD2A36296F009E00A2B8C0 /* AppDelegate.h */, @@ -4825,6 +4834,7 @@ isa = PBXGroup; children = ( 00A416352EC2EAF600ACC877 /* NodeMobile.xcframework */, + AA0000012F70000000000001 /* Shared */, 13B07FAE1A68108700A75B9A /* Quiet */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 00E356EF1AD99517003FC87E /* QuietTests */, @@ -4903,6 +4913,9 @@ dependencies = ( EB4DC7FF2F608A3300EFD23F /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + AA0000012F70000000000001 /* Shared */, + ); name = Quiet; productName = QuietMobile; productReference = 13B07F961A680F5B00A75B9A /* Quiet.app */; @@ -4922,6 +4935,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + AA0000012F70000000000001 /* Shared */, EB4DC7FA2F608A3300EFD23F /* QuietNotificationServiceExtension */, ); name = QuietNotificationServiceExtension; @@ -5329,7 +5343,6 @@ 1868C43A2930D859001D6D5E /* FindFreePort.swift in Sources */, 18FD2A3E296F009E00A2B8C0 /* AppDelegate.m in Sources */, 1868BCED292E9212001D6D5E /* NodeRunner.mm in Sources */, - 665587CA2F4F5ECD005D2086 /* KeychainHandler.swift in Sources */, 1868C43C2930E255001D6D5E /* CommunicationModule.swift in Sources */, 180E120B2AEFB7F900804659 /* Utils.swift in Sources */, ); diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift index 0e5e9f2319..21c4ed417c 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift @@ -29,7 +29,7 @@ class NSEAuthService { os_log("authenticate: got challengeId=%{public}@", log: authLog, type: .debug, challengeResp.challengeId) os_log("authenticate: reading device private key from keychain", log: authLog, type: .debug) - let privateKeyData = try NSEKeychainHelper.getDevicePrivateKey(deviceId: deviceId) + let privateKeyData = try KeychainService.getDevicePrivateKey(deviceId: deviceId) os_log("authenticate: private key read (%{public}d bytes), signing challenge", log: authLog, type: .debug, privateKeyData.count) let proof = try crypto.signChallengePayload(challengeResp.challenge, privateKeyData: privateKeyData) @@ -51,7 +51,7 @@ class NSEAuthService { func fetchNewEntries(teamId: String, afterSeq: Int64) async throws -> LogEntriesResponse { os_log("fetchNewEntries: reading deviceId from keychain", log: authLog, type: .debug) - let deviceId = try NSEKeychainHelper.getDeviceId() + let deviceId = try KeychainService.getDeviceId() os_log("fetchNewEntries: deviceId=%{public}@, authenticating", log: authLog, type: .info, deviceId) let token = try await authenticate(deviceId: deviceId, teamId: teamId) os_log("fetchNewEntries: authenticated, fetching log entries afterSeq=%{public}lld", @@ -72,63 +72,3 @@ class NSEAuthService { } } -// MARK: - Base58 encoder (Bitcoin alphabet) - -enum Base58 { - static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") - - static func encode(_ data: Data) -> String { - let bytes = [UInt8](data) - var leadingZeros = 0 - for b in bytes { - if b == 0 { leadingZeros += 1 } else { break } - } - - var result = [UInt8]() - for byte in bytes { - var carry = Int(byte) - for i in 0.. 0 { - result.append(UInt8(carry % 58)) - carry /= 58 - } - } - - let leading = String(repeating: "1", count: leadingZeros) - let encoded = result.reversed().map { alphabet[Int($0)] } - return leading + String(encoded) - } - - static func decode(_ string: String) -> Data? { - let alphabetMap: [Character: Int] = Dictionary( - uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) } - ) - - var leadingZeros = 0 - for c in string { - if c == "1" { leadingZeros += 1 } else { break } - } - - var result = [UInt8]() - for c in string { - guard let digit = alphabetMap[c] else { return nil } - var carry = digit - for i in 0..>= 8 - } - while carry > 0 { - result.append(UInt8(carry & 0xFF)) - carry >>= 8 - } - } - - let leading = [UInt8](repeating: 0, count: leadingZeros) - return Data(leading + result.reversed()) - } -} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift index 35c4536c0c..bf3a3bfb49 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSECryptoService.swift @@ -155,7 +155,7 @@ class NSECryptoService: DeviceCryptography { private func decryptPayload(_ encryptedPayload: NSEEncryptedPayload, teamId: String) throws -> Any { let keyName = self.makeKeyName(teamId: teamId, scope: encryptedPayload.scope) - let secretKey = try NSEKeychainHelper.getLfaKeyString(keyName: keyName) + let secretKey = try KeychainService.getLfaKeyString(keyName: keyName) return try self.decryptSymmetric(cipherBytes: encryptedPayload.contents, password: secretKey) } diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift deleted file mode 100644 index 745d15b51b..0000000000 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEKeychainHelper.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation -import Security -import OSLog - -struct NSEKeychainHelper { - - // MARK: - Key names (must match what the main app stores) - - private static let devicePrivateKeyPrefix = "quiet.device.privateKey." - private static let deviceIdKey = "quiet.device.id" - private static let lastSyncSeqKey = "quiet.nse.lastSyncSeq" - private static let qssUrlsKey = "quiet.nse.qssUrls" - private static let appIsForegroundKey = "quiet.app.isForeground" - private static let lfaKeyService = "com.quietmobile" - private static let userDefaultsSuite = "group.com.quietmobile" - - // MARK: - Device private key - - static func getDevicePrivateKey(deviceId: String) throws -> Data { - let account = devicePrivateKeyPrefix + deviceId - // Stored as UTF-8 Base58 string (LFA secretKey format); decode to raw bytes - let rawData = try readData(account: account, label: "device private key") - guard let base58String = String(data: rawData, encoding: .utf8), - let keyBytes = Base58.decode(base58String) else { - throw NSEAuthError.keychainError("device private key is not valid Base58") - } - return keyBytes - } - - // MARK: - Device ID - - static func getDeviceId() throws -> String { - let data = try readData(account: deviceIdKey, label: "device ID") - guard let str = String(data: data, encoding: .utf8) else { - throw NSEAuthError.keychainError("device ID is not valid UTF-8") - } - return str - } - - static func getLfaKeyString(keyName: String) throws -> String { - let data = try readData(account: keyName, label: "LFA key", service: lfaKeyService) - guard let str = String(data: data, encoding: .utf8) else { - throw NSEAuthError.keychainError("LFA key '\(keyName)' is not valid UTF-8") - } - return str - } - - // MARK: - Last sync state (UserDefaults — not sensitive) - - static func getLastSyncSeq() -> Int64 { - let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - return Int64(defaults.integer(forKey: lastSyncSeqKey)) - } - - static func saveLastSyncSeq(_ seq: Int64) { - let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - let current = Int64(defaults.integer(forKey: lastSyncSeqKey)) - os_log("Saving last sync seq: current=%{public}lld, new=%{public}lld", current, seq) - defaults.set(Int(max(current, seq)), forKey: lastSyncSeqKey) - } - - static func getQssUrl(teamId: String) -> URL? { - let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - guard - let qssUrls = defaults.dictionary(forKey: qssUrlsKey) as? [String: String], - let qssUrlString = qssUrls[teamId] - else { - return nil - } - - return URL(string: qssUrlString) - } - - static func isMainAppForeground() -> Bool { - let defaults = UserDefaults(suiteName: userDefaultsSuite) ?? UserDefaults.standard - return defaults.bool(forKey: appIsForegroundKey) - } - - // MARK: - Private helpers - - // Must match the shared keychain entitlement in both the main app and NSE targets. - private static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String - - private static func requiredAccessGroup() throws -> String { - guard let accessGroup else { - throw NSEAuthError.keychainError("Missing QuietKeychainAccessGroup configuration") - } - return accessGroup - } - - private static func readData(account: String, label: String, service: String? = nil) throws -> Data { - // Note: kSecAttrAccessible is intentionally omitted — it's a write attribute. - // Including it in a read query can cause silent failures on some iOS versions. - // Accessibility is enforced at write time (kSecAttrAccessibleAfterFirstUnlock). - var query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: account, - kSecReturnData: true, - kSecMatchLimit: kSecMatchLimitOne, - ] - query[kSecAttrAccessGroup] = try requiredAccessGroup() - if let service { - query[kSecAttrService] = service - } - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - switch status { - case errSecSuccess: - guard let data = result as? Data else { - throw NSEAuthError.keychainError("Unexpected type for \(label)") - } - return data - case errSecItemNotFound: - throw NSEAuthError.missingCredentials(label) - default: - throw NSEAuthError.keychainError("SecItemCopyMatching failed for \(label): \(status)") - } - } -} diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index e305958281..0a2bac3652 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -4,9 +4,6 @@ import os.log private let nseLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NotificationService") class NotificationService: UNNotificationServiceExtension { - private static let appGroupIdentifier = "group.com.quietmobile" - private static let badgeCountKey = "quiet.nse.badgeCount" - private struct DecryptedEntry { let entry: LogEntry let message: NSEDecryptedNotificationMessage @@ -55,7 +52,7 @@ class NotificationService: UNNotificationServiceExtension { os_log("fetchAndUpdate: start", log: nseLog, type: .info) - if NSEKeychainHelper.isMainAppForeground() { + if SharedDefaults.isMainAppForeground() { os_log("fetchAndUpdate: app is foregrounded, skipping NSE fetch/decrypt work", log: nseLog, type: .info) return } @@ -67,7 +64,7 @@ class NotificationService: UNNotificationServiceExtension { return } - guard let qssUrl = NSEKeychainHelper.getQssUrl(teamId: teamId) else { + guard let qssUrl = SharedDefaults.getQssUrl(teamId: teamId) else { os_log("fetchAndUpdate: missing stored QSS URL for teamId=%{public}@", log: nseLog, type: .error, teamId) return @@ -91,7 +88,7 @@ class NotificationService: UNNotificationServiceExtension { auth = newAuth } - let afterSeq = NSEKeychainHelper.getLastSyncSeq() + let afterSeq = SharedDefaults.getLastSyncSeq() os_log("fetchAndUpdate: fetching entries afterSeq=%{public}lld", log: nseLog, type: .info, afterSeq) @@ -128,7 +125,7 @@ class NotificationService: UNNotificationServiceExtension { type: .info, maxSyncSeq ) - NSEKeychainHelper.saveLastSyncSeq(maxSyncSeq) + SharedDefaults.saveLastSyncSeq(maxSyncSeq) guard let content = bestAttemptContent else { os_log("fetchAndUpdate: bestAttemptContent is nil, cannot update badge", log: nseLog, type: .error) @@ -154,12 +151,11 @@ class NotificationService: UNNotificationServiceExtension { } let badgeIncrement = decryptedEntries.isEmpty ? notificationEntries.count : decryptedEntries.count - let defaults = UserDefaults(suiteName: Self.appGroupIdentifier) ?? UserDefaults.standard - let storedBadgeCount = max(0, defaults.integer(forKey: Self.badgeCountKey)) + let storedBadgeCount = SharedDefaults.getBadgeCount() let newBadge = storedBadgeCount + badgeIncrement let badgeNumber = NSNumber(value: newBadge) os_log("fetchAndUpdate: updating badge to %{public}d", log: nseLog, type: .info, newBadge) - defaults.set(newBadge, forKey: Self.badgeCountKey) + SharedDefaults.setBadgeCount(newBadge) if let latestDecryptedEntry = decryptedEntries.last { for decryptedEntry in decryptedEntries.dropLast() { diff --git a/packages/mobile/ios/Shared/Base58.swift b/packages/mobile/ios/Shared/Base58.swift new file mode 100644 index 0000000000..bffd809cbc --- /dev/null +++ b/packages/mobile/ios/Shared/Base58.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Base58 encoder/decoder (Bitcoin alphabet). +/// Shared between the main app and NSE targets. +enum Base58 { + static let alphabet = Array("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz") + + static func encode(_ data: Data) -> String { + let bytes = [UInt8](data) + var leadingZeros = 0 + for b in bytes { + if b == 0 { leadingZeros += 1 } else { break } + } + + var result = [UInt8]() + for byte in bytes { + var carry = Int(byte) + for i in 0.. 0 { + result.append(UInt8(carry % 58)) + carry /= 58 + } + } + + let leading = String(repeating: "1", count: leadingZeros) + let encoded = result.reversed().map { alphabet[Int($0)] } + return leading + String(encoded) + } + + static func decode(_ string: String) -> Data? { + let alphabetMap: [Character: Int] = Dictionary( + uniqueKeysWithValues: alphabet.enumerated().map { ($1, $0) } + ) + + var leadingZeros = 0 + for c in string { + if c == "1" { leadingZeros += 1 } else { break } + } + + var result = [UInt8]() + for c in string { + guard let digit = alphabetMap[c] else { return nil } + var carry = digit + for i in 0..>= 8 + } + while carry > 0 { + result.append(UInt8(carry & 0xFF)) + carry >>= 8 + } + } + + let leading = [UInt8](repeating: 0, count: leadingZeros) + return Data(leading + result.reversed()) + } +} diff --git a/packages/mobile/ios/Shared/KeychainService.swift b/packages/mobile/ios/Shared/KeychainService.swift new file mode 100644 index 0000000000..5af6873e18 --- /dev/null +++ b/packages/mobile/ios/Shared/KeychainService.swift @@ -0,0 +1,257 @@ +import Foundation +import Security +import OSLog + +// MARK: - Error type + +public enum KeychainServiceError: Error { + case itemNotFound(String) + case unexpectedData(String) + case missingAccessGroup + case operationFailed(status: OSStatus) +} + +// MARK: - Write result + +public enum KeyAddStatus { + case success + case duplicateScope +} + +public struct NamedKey: Codable { + let keyName: String + let key: String +} + +// MARK: - KeychainService + +struct KeychainService { + private static let lfaKeyService = "com.quietmobile" + private static let devicePrivateKeyPrefix = "quiet.device.privateKey." + private static let deviceIdKey = "quiet.device.id" + private static let teamIdKey = "quiet.team.id" + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "com.quietmobile", + category: "KeychainService" + ) + + private static var accessGroup: String? { + Bundle.main.object(forInfoDictionaryKey: "QuietKeychainAccessGroup") as? String + } + + private static func requiredAccessGroup() throws -> String { + guard let accessGroup else { + throw KeychainServiceError.missingAccessGroup + } + return accessGroup + } + + // MARK: - Generic read + + /// Read raw data for a generic password keychain item. + /// `service` is optional — omit it for items stored without a service attribute. + static func readData(account: String, service: String? = nil) throws -> Data { + // kSecAttrAccessible intentionally omitted on reads — it's a write-time attribute. + // Including it can cause silent failures on some iOS versions. + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + query[kSecAttrAccessGroup] = try requiredAccessGroup() + if let service { + query[kSecAttrService] = service + } + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + switch status { + case errSecSuccess: + guard let data = result as? Data else { + throw KeychainServiceError.unexpectedData(account) + } + return data + case errSecItemNotFound: + throw KeychainServiceError.itemNotFound(account) + default: + throw KeychainServiceError.operationFailed(status: status) + } + } + + /// Read a UTF-8 string for a generic password keychain item. + static func readString(account: String, service: String? = nil) throws -> String { + let data = try readData(account: account, service: service) + guard let str = String(data: data, encoding: .utf8) else { + throw KeychainServiceError.unexpectedData(account) + } + return str + } + + // MARK: - Generic write + + /// Write data to a generic password keychain item. + /// Returns `.success` if written, `.duplicateScope` if an item with a different value already exists. + static func writeData(account: String, data: Data, service: String? = nil) throws -> KeyAddStatus { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock, + ] + query[kSecAttrAccessGroup] = try requiredAccessGroup() + if let service { + query[kSecAttrService] = service + } + + let status = SecItemAdd(query as CFDictionary, nil) + switch status { + case errSecSuccess: + return .success + case errSecDuplicateItem: + return .duplicateScope + default: + throw KeychainServiceError.operationFailed(status: status) + } + } + + // MARK: - Generic delete + + /// Delete a single generic password keychain item. + static func delete(account: String, service: String? = nil) throws { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: account, + ] + query[kSecAttrAccessGroup] = try requiredAccessGroup() + if let service { + query[kSecAttrService] = service + } + + let status = SecItemDelete(query as CFDictionary) + logDeletion(account: account, service: service, status: status) + if status != errSecSuccess && status != errSecItemNotFound { + throw KeychainServiceError.operationFailed(status: status) + } + } + + // MARK: - List accounts + + /// List all account names for generic password items, optionally filtered by service. + static func listAccounts(service: String? = nil) throws -> [String] { + var query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitAll, + ] + query[kSecAttrAccessGroup] = try requiredAccessGroup() + if let service { + query[kSecAttrService] = service + } + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecItemNotFound { return [] } + guard status == errSecSuccess else { + throw KeychainServiceError.operationFailed(status: status) + } + + let items: [[String: Any]] + if let single = result as? [String: Any] { + items = [single] + } else if let many = result as? [[String: Any]] { + items = many + } else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + /// Delete all generic password items whose account starts with `prefix`. + static func deleteAll(matchingPrefix prefix: String, service: String? = nil) throws { + let accounts = try listAccounts(service: service) + for account in accounts where account.hasPrefix(prefix) { + try delete(account: account, service: service) + } + } + + // MARK: - Domain-specific: LFA keys + + static func getLfaKeyString(keyName: String) throws -> String { + try readString(account: keyName, service: lfaKeyService) + } + + static func addLfaKey(keyName: String, key: String) throws -> KeyAddStatus { + // Check if already stored with same value + if let existing = try? readString(account: keyName, service: lfaKeyService) { + return existing == key ? .success : .duplicateScope + } + + guard let keyData = key.data(using: .utf8) else { + throw KeychainServiceError.unexpectedData(keyName) + } + return try writeData(account: keyName, data: keyData, service: lfaKeyService) + } + + // MARK: - Upsert + + /// Delete-then-add to allow updating an existing item's value. + static func upsertString(account: String, value: String, service: String? = nil) throws { + guard let data = value.data(using: .utf8) else { + throw KeychainServiceError.unexpectedData(account) + } + try? delete(account: account, service: service) + _ = try writeData(account: account, data: data, service: service) + } + + // MARK: - Domain-specific: device credentials + + static func getDeviceId() throws -> String { + try readString(account: deviceIdKey) + } + + static func saveDeviceCredentials(deviceId: String, teamId: String, signingPrivateKey: String) throws { + try upsertString(account: deviceIdKey, value: deviceId) + try upsertString(account: teamIdKey, value: teamId) + try upsertString(account: devicePrivateKeyPrefix + deviceId, value: signingPrivateKey) + } + + static func getDevicePrivateKey(deviceId: String) throws -> Data { + let account = devicePrivateKeyPrefix + deviceId + let rawData = try readData(account: account) + guard let base58String = String(data: rawData, encoding: .utf8), + let keyBytes = Base58.decode(base58String) else { + throw KeychainServiceError.unexpectedData("device private key is not valid Base58") + } + return keyBytes + } + + // MARK: - Domain-specific: clear all Quiet data + + static func clearAllQuietData() throws { + logger.info("clearAllQuietData: starting keychain cleanup") + try deleteAll(matchingPrefix: "quiet_", service: lfaKeyService) + try deleteAll(matchingPrefix: "quiet.device.privateKey.") + try delete(account: deviceIdKey) + try delete(account: teamIdKey) + logger.info("clearAllQuietData: finished keychain cleanup") + } + + // MARK: - Logging + + private static func logDeletion(account: String, service: String?, status: OSStatus) { + let serviceLabel = service ?? "" + switch status { + case errSecSuccess: + logger.info("Deleted keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public)") + case errSecItemNotFound: + logger.debug("Keychain item not found account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public)") + default: + logger.error("Failed to delete keychain item account=\(account, privacy: .public) service=\(serviceLabel, privacy: .public) status=\(status, privacy: .public)") + } + } +} diff --git a/packages/mobile/ios/Shared/SharedDefaults.swift b/packages/mobile/ios/Shared/SharedDefaults.swift new file mode 100644 index 0000000000..ecbe532272 --- /dev/null +++ b/packages/mobile/ios/Shared/SharedDefaults.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Shared UserDefaults state used by both the main app and the NSE. +/// All keys live in the app group suite so both processes see the same values. +struct SharedDefaults { + static let suiteName = "group.com.quietmobile" + + // MARK: - Keys + + static let lastSyncSeqKey = "quiet.nse.lastSyncSeq" + static let lastSyncTeamIdKey = "quiet.nse.lastSyncTeamId" + static let qssUrlsKey = "quiet.nse.qssUrls" + static let badgeCountKey = "quiet.nse.badgeCount" + static let appIsForegroundKey = "quiet.app.isForeground" + + static let defaults: UserDefaults = { + guard let suite = UserDefaults(suiteName: suiteName) else { + fatalError("SharedDefaults: failed to create UserDefaults for suite '\(suiteName)' — check app group entitlements") + } + return suite + }() + + // MARK: - Foreground state + + static func setAppForeground(_ foreground: Bool) { + defaults.set(foreground, forKey: appIsForegroundKey) + } + + static func isMainAppForeground() -> Bool { + defaults.bool(forKey: appIsForegroundKey) + } + + // MARK: - Badge count + + static func getBadgeCount() -> Int { + max(0, defaults.integer(forKey: badgeCountKey)) + } + + static func setBadgeCount(_ count: Int) { + defaults.set(count, forKey: badgeCountKey) + } + + // MARK: - Sync seq + + static func getLastSyncSeq() -> Int64 { + defaults.object(forKey: lastSyncSeqKey) as? Int64 ?? Int64(defaults.integer(forKey: lastSyncSeqKey)) + } + + static func saveLastSyncSeq(_ seq: Int64) { + let current = getLastSyncSeq() + let newSeq = max(current, seq) + defaults.set(newSeq, forKey: lastSyncSeqKey) + } + + static func saveLastSyncSeqAndTeam(_ seq: Int64, teamId: String) { + let current = getLastSyncSeq() + guard seq > current else { return } + defaults.set(seq, forKey: lastSyncSeqKey) + defaults.set(teamId, forKey: lastSyncTeamIdKey) + } + + // MARK: - QSS URLs + + static func getQssUrl(teamId: String) -> URL? { + guard + let qssUrls = defaults.dictionary(forKey: qssUrlsKey) as? [String: String], + let urlString = qssUrls[teamId] + else { return nil } + return URL(string: urlString) + } + + static func saveQssUrl(teamId: String, url: String) { + var existing = defaults.dictionary(forKey: qssUrlsKey) as? [String: String] ?? [:] + existing[teamId] = url + defaults.set(existing, forKey: qssUrlsKey) + } + + // MARK: - Clear all + + static func clearAll() { + defaults.removeObject(forKey: lastSyncSeqKey) + defaults.removeObject(forKey: lastSyncTeamIdKey) + defaults.removeObject(forKey: qssUrlsKey) + defaults.removeObject(forKey: badgeCountKey) + } +} diff --git a/packages/mobile/ios/UserMetadataHandler.swift b/packages/mobile/ios/UserMetadataHandler.swift index ae52be27db..5522055809 100644 --- a/packages/mobile/ios/UserMetadataHandler.swift +++ b/packages/mobile/ios/UserMetadataHandler.swift @@ -144,7 +144,7 @@ class UserMetadataHandler: NSObject { try context.save() } catch { UserMetadataHandler.logger.error("Error while persisting UserMetadata model(s) to disk: \(error)") - throw KeychainHandlerError.unhandledError(reason: error) + throw UserMetadataError.unhandledError(reason: error) } } From 17056f56ac27d8f33c1186e92da417b1b9ac89e8 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 6 Apr 2026 22:54:00 -0400 Subject: [PATCH 49/92] fix tests; move qss url emit to nse to qss.service --- .../backend/src/nest/auth/sigchain.service.ts | 9 +- .../connections-manager.service.spec.ts | 93 ------------------- .../connections-manager.service.ts | 54 ----------- .../userProfile-sync.spec.ts | 20 ++-- .../backend/src/nest/qps/qps.service.spec.ts | 39 -------- .../backend/src/nest/qss/qss.service.spec.ts | 79 ++++++++++++++++ packages/backend/src/nest/qss/qss.service.ts | 58 +++++++++++- .../notificationTokens.store.spec.ts | 20 +++- .../storage/orbitDb/orbitDb.service.spec.ts | 3 +- .../userProfile/userProfile.store.spec.ts | 2 +- .../storage/userProfile/userProfile.store.ts | 25 ++--- 11 files changed, 188 insertions(+), 214 deletions(-) diff --git a/packages/backend/src/nest/auth/sigchain.service.ts b/packages/backend/src/nest/auth/sigchain.service.ts index 971cdca7de..aaaec63f29 100644 --- a/packages/backend/src/nest/auth/sigchain.service.ts +++ b/packages/backend/src/nest/auth/sigchain.service.ts @@ -143,10 +143,14 @@ export class SigChainService extends EventEmitter { private handleChainUpdate = (teamName: string) => { this._updateUsersOnChainUpdate(teamName) - void this._updateKeysOnChainUpdate(teamName) + void this._updateKeysOnChainUpdate(teamName).catch(err => { + this.logger.error('Failed to update iOS keychain on chain update', err) + }) this._updateDeviceCredentials(teamName) this.emit('updated', teamName) - this.saveChain(teamName) + void this.saveChain(teamName).catch(err => { + this.logger.error('Failed to save chain after update', err) + }) this.logger.info('Chain updated, emitted updated event') } @@ -185,6 +189,7 @@ export class SigChainService extends EventEmitter { } const teamId = sigchain.team!.id + await this._ensureDb() const alreadySentKeys: Set = new Set(await this.localDbService.getKeysStoredInKeychain(teamId)) const keysToSend: StorableKey[] = [] const keyNamesSent: string[] = [] diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts index 25576ef793..6f4f1d662d 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.spec.ts @@ -138,99 +138,6 @@ describe('ConnectionsManagerService', () => { expect(launchSpy).toBeCalledTimes(1) }) - it('emits the authoritative QSS URL for the NSE on iOS', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'ios', - }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEndpoint: 'wss://community.example/ws', - }) - await localDbService.setCurrentCommunityId(community.id) - - qssService._qssEndpoint = 'wss://authoritative.example' - - const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') - - await (connectionsManagerService as any).emitNseQssUrl() - - expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { - teamId: 'team-id', - qssUrl: 'https://authoritative.example', - }) - } finally { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) - } - }) - - it('falls back to the stored community QSS endpoint when no authoritative endpoint is available', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'ios', - }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEndpoint: 'ws://community.example/ws', - }) - await localDbService.setCurrentCommunityId(community.id) - - qssService._qssEndpoint = undefined as any - - const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') - - await (connectionsManagerService as any).emitNseQssUrl() - - expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { - teamId: 'team-id', - qssUrl: 'http://community.example/ws', - }) - } finally { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) - } - }) - - it('skips NSE QSS URL emission when no valid ws or wss endpoint can be derived', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { - value: 'ios', - }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEndpoint: 'https://community.example/api', - }) - await localDbService.setCurrentCommunityId(community.id) - - qssService._qssEndpoint = 'https://authoritative.example/api' - - const emitSpy = jest.spyOn(connectionsManagerService.serverIoProvider.io, 'emit') - - await (connectionsManagerService as any).emitNseQssUrl() - - expect(emitSpy).not.toHaveBeenCalledWith( - SocketEvents.NSE_QSS_URL_UPDATED, - expect.objectContaining({ teamId: 'team-id' }) - ) - } finally { - Object.defineProperty(process, 'platform', { - value: originalPlatform, - }) - } - }) - it('pauses and resumes qss alongside the mobile lifecycle', async () => { const closeSocketSpy = jest.spyOn(connectionsManagerService, 'closeSocket').mockResolvedValue() const openSocketSpy = jest.spyOn(connectionsManagerService, 'openSocket').mockResolvedValue() diff --git a/packages/backend/src/nest/connections-manager/connections-manager.service.ts b/packages/backend/src/nest/connections-manager/connections-manager.service.ts index a98dd60192..fa8ffd0c6c 100644 --- a/packages/backend/src/nest/connections-manager/connections-manager.service.ts +++ b/packages/backend/src/nest/connections-manager/connections-manager.service.ts @@ -50,7 +50,6 @@ import { InvitationData, SetUserProfileResponse, UserProfilesUpdatedPayload, - NseQssUrlUpdatedEvent, } from '@quiet/types' import { CONFIG_OPTIONS, QSS_ALLOWED, QSS_ENDPOINT, SERVER_IO_PROVIDER, SOCKS_PROXY_AGENT } from '../const' import { Libp2pService, Libp2pState } from '../libp2p/libp2p.service' @@ -601,7 +600,6 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI // Unblock websocket endpoints this.socketService.resolveReadyness() - void this.emitNseQssUrl() this.serverIoProvider.io.emit(SocketEvents.COMMUNITY_LAUNCHED, { id: community.id, } as LaunchCommunityPayload) @@ -687,62 +685,10 @@ export class ConnectionsManagerService extends EventEmitter implements OnModuleI }) } - private getNseQssUrl(wsUrl: string | undefined): string | undefined { - if (wsUrl == null || wsUrl === '') { - this.logger.warn('Skipping NSE QSS URL update because wsUrl is empty') - return undefined - } - - if (wsUrl.startsWith('wss://')) { - return `https://${wsUrl.slice('wss://'.length)}` - } - - if (wsUrl.startsWith('ws://')) { - return `http://${wsUrl.slice('ws://'.length)}` - } - - this.logger.warn('Skipping NSE QSS URL update because endpoint is not ws/wss', wsUrl) - return undefined - } - - private async emitNseQssUrl(): Promise { - if ((process.platform as string) !== 'ios') { - return - } - - const community = await this.localDbService.getCurrentCommunity() - if (community?.teamId == null) { - this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found') - this.logger.warn('Community', community) - return - } - - const wsUrl = community.qssEndpoint ?? this.qssEndpoint ?? this.qssService.qssEndpoint - const qssUrl = this.getNseQssUrl(wsUrl) - if (qssUrl == null) { - this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') - return - } - - const payload: NseQssUrlUpdatedEvent = { - teamId: community.teamId, - qssUrl, - } - - this.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) - } - /** * Attaches listeners for events received from the state manager */ private attachSocketServiceListeners() { - this.serverIoProvider.io.on(SocketActions.CONNECTION, socket => { - void this.emitNseQssUrl() - socket.on(SocketActions.START, () => { - void this.emitNseQssUrl() - }) - }) - // Community this.socketService.on(SocketActions.CONNECTION, () => { this.logger.info(`socketService - ${SocketActions.CONNECTION}`) diff --git a/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts b/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts index e1d2e42a49..e580d82e9a 100644 --- a/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts +++ b/packages/backend/src/nest/libp2p/integration-tests/userProfile-sync.spec.ts @@ -276,7 +276,7 @@ describe('UserProfileStore OrbitDB Sync', () => { 10000, 100 ) - userProfileStores[N_PEERS - 1].startSync() + await userProfileStores[N_PEERS - 1].startSync() await waitForExpect( async () => { @@ -291,11 +291,17 @@ describe('UserProfileStore OrbitDB Sync', () => { 1000 ) - // Ensure only the new peer emitted 'updated' - for (let i = 0; i < N_PEERS - 1; i++) { - expect(updatedSpies[i]).toHaveBeenCalledTimes(1) - } - logger.info('New peer updated:', updatedSpies[N_PEERS - 1].mock.calls.length, 'times') - expect(updatedSpies[N_PEERS - 1]).toHaveBeenCalled() + await waitForExpect( + async () => { + // Ensure only the new peer emitted 'updated' + for (let i = 0; i < N_PEERS - 1; i++) { + expect(updatedSpies[i]).toHaveBeenCalledTimes(1) + } + logger.info('New peer updated:', updatedSpies[N_PEERS - 1].mock.calls.length, 'times') + expect(updatedSpies[N_PEERS - 1]).toHaveBeenCalled() + }, + 5000, + 100 + ) }) }) diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index 7ad334ef25..97049a7ddb 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -366,25 +366,6 @@ describe('QPSService', () => { ) }) - it('strips qssUrl from push data before sending to QPS', async () => { - await qpsService.sendBatchPush(TEAM_ID, 'title', 'body', { - cid: 'cid-1', - qssUrl: 'https://untrusted.example', - }) - - expect(qssClient.sendMessage).toHaveBeenCalledWith( - WebsocketEvents.SEND_BATCH_PUSH, - expect.objectContaining({ - payload: expect.objectContaining({ - title: 'title', - body: 'body', - data: { teamId: TEAM_ID, cid: 'cid-1' }, - }), - }), - true - ) - }) - it('skips when QPS is disabled', async () => { const disabled = new QPSService( false, @@ -490,26 +471,6 @@ describe('QPSService', () => { qssClient.sendMessage.mockResolvedValue(pushSuccessResponse) }) - it('strips qssUrl from single push data before sending to QPS', async () => { - await qpsService.sendPush('ucan-user-a', 'title', 'body', { - cid: 'cid-1', - qssUrl: 'https://untrusted.example', - }) - - expect(qssClient.sendMessage).toHaveBeenCalledWith( - WebsocketEvents.SEND_PUSH, - expect.objectContaining({ - payload: { - ucan: 'ucan-user-a', - title: 'title', - body: 'body', - data: { cid: 'cid-1' }, - }, - }), - true - ) - }) - it('skips single push when QSS is not connected', async () => { qssClient.connected = false diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index d0047b953c..425db6834c 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -197,6 +197,85 @@ describe('QSSService', () => { expect(qssService.canConnect).toBeTruthy() }) + it('emits the NSE QSS URL from the endpoint passed to connect on iOS', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect('wss://community.example/ws') + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'https://community.example/ws', + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + + it('emits the NSE QSS URL from the stored endpoint when connect is called without one on iOS', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + + qssService._qssEndpoint = 'ws://configured.example/ws' + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect(undefined) + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'http://configured.example/ws', + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + + it('skips NSE QSS URL emission when connect uses a non-ws endpoint on iOS', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect('https://community.example/api') + + expect(emitSpy).not.toHaveBeenCalledWith( + SocketEvents.NSE_QSS_URL_UPDATED, + expect.objectContaining({ teamId: 'team-id' }) + ) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + it(`doesn't connect to QSS when not enabled and an endpoint string is provided`, async () => { await initCommunity() mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(false) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index d3c048ac1e..8e07a63e07 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -43,7 +43,14 @@ import { DLQDecryptEntry } from '../local-db/local-db.types' import { LogUpdate } from '../storage/orbitDb/orbitdb.types' import { logEntryToLogUpdate } from '../storage/orbitDb/util' import { QSS_RECONNECT_DELAY_MS, QSSAuthConnStatus } from './qss.const' -import { CompoundError, InvitationDataV3, NseSyncSeqUpdatedEvent, SocketActions, SocketEvents } from '@quiet/types' +import { + CompoundError, + InvitationDataV3, + NseQssUrlUpdatedEvent, + NseSyncSeqUpdatedEvent, + SocketActions, + SocketEvents, +} from '@quiet/types' import { LocalDbEvents } from '../local-db/local-db.types' import { SocketService } from '../socket/socket.service' import { Serializer } from '../common/serializer.service' @@ -430,15 +437,19 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul const initStatus = await this.getQssInitStatus() if (!initStatus.communityInitialized) { this.logger.warn(`Can't determine if QSS is enabled because the community hasn't been initialized in local DB`) + this._connecting = false return QSSOperationResult.ERROR } if (!initStatus.qssEnabled) { this.logger.warn(`Can't connect to QSS because QSS is disabled on this community`) + this._connecting = false return QSSOperationResult.DISABLED } } + await this.emitNseQssUrl(this._qssEndpoint) + // wait for our socket to finish connecting let connStatus: QSSOperationResult try { @@ -455,6 +466,51 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul return connStatus } + private getNseQssUrl(wsUrl: string | undefined): string | undefined { + if (wsUrl == null || wsUrl === '') { + this.logger.warn('Skipping NSE QSS URL update because wsUrl is empty') + return undefined + } + + if (wsUrl.startsWith('wss://')) { + return `https://${wsUrl.slice('wss://'.length)}` + } + + if (wsUrl.startsWith('ws://')) { + return `http://${wsUrl.slice('ws://'.length)}` + } + + this.logger.warn('Skipping NSE QSS URL update because endpoint is not ws/wss', wsUrl) + return undefined + } + + private async emitNseQssUrl(wsUrl: string | undefined): Promise { + if ((process.platform as string) !== 'ios') { + return + } + + const community = await this.localDbService.getCurrentCommunity() + const teamId = community?.teamId ?? this.sigChainService.team?.id + if (teamId == null) { + this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found') + this.logger.warn('Community', community) + return + } + + const qssUrl = this.getNseQssUrl(wsUrl) + if (qssUrl == null) { + this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') + return + } + + const payload: NseQssUrlUpdatedEvent = { + teamId, + qssUrl, + } + + this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) + } + /** * Add a community to QSS and start syncing our chain with QSS * diff --git a/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts b/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts index 9a1d7b4b33..ded84c0680 100644 --- a/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts +++ b/packages/backend/src/nest/storage/notifications/notificationTokens.store.spec.ts @@ -149,7 +149,10 @@ describe('NotificationTokensStore/validateEntry', () => { encrypted: 'fake-encrypted', } const decEntry: PushNotificationTokens = { userId: aliceUserId, tokens: ['ucan-1'] } - const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any) + const store = new NotificationTokensStore( + {} as any, + { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any + ) jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry) const entry = { hash: 'fakehash', @@ -168,7 +171,10 @@ describe('NotificationTokensStore/validateEntry', () => { encrypted: 'fake-encrypted', } const decEntry: any = { userId: aliceUserId, tokens: 'not-an-array' } - const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any) + const store = new NotificationTokensStore( + {} as any, + { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any + ) jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry) const entry = { hash: 'fakehash', @@ -190,7 +196,10 @@ describe('NotificationTokensStore/validateEntry', () => { userId: aliceUserId, tokens: Array.from({ length: 11 }, (_, i) => `ucan-${i}`), } - const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any) + const store = new NotificationTokensStore( + {} as any, + { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any + ) jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry) const entry = { hash: 'fakehash', @@ -208,7 +217,10 @@ describe('NotificationTokensStore/validateEntry', () => { signature: { author: { name: aliceUserId } }, encrypted: 'fake-encrypted', } - const store = new NotificationTokensStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any) + const store = new NotificationTokensStore( + {} as any, + { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any + ) jest.spyOn(store, 'decryptEntry').mockRejectedValue(new Error('decryption failed')) const entry = { hash: 'fakehash', diff --git a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts index f786bce0f9..aaf58072cb 100644 --- a/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts +++ b/packages/backend/src/nest/storage/orbitDb/orbitDb.service.spec.ts @@ -96,7 +96,8 @@ describe('OrbitDbService', () => { expect(orbitDbService.identities).toBeUndefined() }) - it('does not throw an error when accessing orbitDb after creating instance', () => { + it('does not throw an error when accessing orbitDb after creating instance', async () => { + await orbitDbService.create(ipfsService.ipfsInstance!) expect(() => orbitDbService.orbitDb).not.toThrowError('[get orbitDb]:no orbitDbInstance') }) diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts index 46b87f2441..2b1ca7a2dc 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.spec.ts @@ -213,7 +213,7 @@ describe('UserProfileStore/validateEntry', () => { } const decEntry: any = { userId: aliceUserId } // Patch decryptEntry to return decEntry - const store = new UserProfileStore({} as any, { crypto: {}, user: { userId: aliceUserId } } as any) + const store = new UserProfileStore({} as any, { crypto: {}, user: { userId: aliceUserId }, on: jest.fn() } as any) jest.spyOn(store, 'decryptEntry').mockResolvedValue(decEntry) jest.spyOn(UserProfileStore, 'validateUserProfile').mockResolvedValue({ success: true }) const entry = { diff --git a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts index da9e9d8425..893fd0dedb 100644 --- a/packages/backend/src/nest/storage/userProfile/userProfile.store.ts +++ b/packages/backend/src/nest/storage/userProfile/userProfile.store.ts @@ -21,18 +21,6 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase > { private deferredProfiles: UserProfile[] = [] private nicknameMaps: Map = new Map() - private readonly handleAuthUpdated = async (): Promise => { - if (!this.store) { - return - } - - try { - await this.flushDeferredEntries() - await this.store.retryIndexingUnindexedEntries() - } catch (err) { - logger.error('Failed to update user profiles:', err) - } - } constructor( private readonly orbitDbService: OrbitDbService, @@ -69,6 +57,19 @@ export class UserProfileStore extends EncryptedKeyValueIndexedValidatedStoreBase }) } + private readonly handleAuthUpdated = async (): Promise => { + if (!this.store) { + return + } + + try { + await this.flushDeferredEntries() + await this.store.retryIndexingUnindexedEntries() + } catch (err) { + logger.error('Failed to update user profiles:', err) + } + } + /** * Starts synchronization of the user profiles store and flushes deferred entries. */ From 0ddc737651dd8e809340f470766e4012970ef42d Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 15 Apr 2026 20:53:18 -0400 Subject: [PATCH 50/92] update qss submodule --- 3rd-party/qss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rd-party/qss b/3rd-party/qss index c026e9fc3f..aadb8b5778 160000 --- a/3rd-party/qss +++ b/3rd-party/qss @@ -1 +1 @@ -Subproject commit c026e9fc3f6203628d64b7c31ca709e14f2a38d1 +Subproject commit aadb8b57783e10197fb64dab32e9bfe3e28fa866 From 3a6751532ec3305d5139403bc1ef2c4042c5b299 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 15 Apr 2026 20:53:50 -0400 Subject: [PATCH 51/92] add google-services.json to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3782f31124..93c02bc425 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ clangd/ # Firebase GoogleService-Info.plist +google-services.json From 55c50aafea20514eaae48800866f2ca5367b3a93 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 16 Apr 2026 14:43:58 -0400 Subject: [PATCH 52/92] update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f54f12b19e..8e5097b7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features +* Adds ios push notification support [#3087](https://github.com/TryQuiet/quiet/issues/3087) + ### Fixes ### Chores From c4966d069c41a6503ae36f782fac2ae2e4c1113e Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 16 Apr 2026 16:59:06 -0400 Subject: [PATCH 53/92] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e5097b7b5..0463edc68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [unreleased] +## [7.1.0] ### Features From ced25514ab6a4a862f57f882ecac51227d85ebe8 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 11:02:28 -0400 Subject: [PATCH 54/92] include platform when registering --- .../backend/src/nest/qps/qps.service.spec.ts | 37 +++++++++++-------- packages/backend/src/nest/qps/qps.service.ts | 30 ++++++++++----- packages/backend/src/nest/qps/qps.types.ts | 1 + .../backend/src/nest/socket/socket.service.ts | 9 +++-- .../pushNotifications.master.saga.ts | 11 +++++- .../pushNotifications.slice.ts | 9 ++++- .../pushNotifications/sendDeviceToken.saga.ts | 13 +++++-- .../src/utils/tests/factories.ts | 12 ++++-- packages/types/src/socket.ts | 6 ++- 9 files changed, 90 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/nest/qps/qps.service.spec.ts b/packages/backend/src/nest/qps/qps.service.spec.ts index 97049a7ddb..5035d5f9c3 100644 --- a/packages/backend/src/nest/qps/qps.service.spec.ts +++ b/packages/backend/src/nest/qps/qps.service.spec.ts @@ -53,6 +53,11 @@ describe('QPSService', () => { const TOKEN = 'fake-device-token-abc123' const TEAM_ID = 'test-team-id' + const DEVICE_TOKEN_PAYLOAD = { + deviceToken: TOKEN, + bundleId: 'com.quietmobile', + platform: 'ios' as const, + } const successResponse = { ts: DateTime.utc().toMillis(), @@ -106,14 +111,14 @@ describe('QPSService', () => { it('sends immediately when ready', async () => { setReady() - const result = await qpsService.register(TOKEN) + const result = await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(result?.payload).toEqual({ ucan: 'test-ucan' }) expect(qssClient.sendMessage).toHaveBeenCalledWith( WebsocketEvents.REGISTER_DEVICE_TOKEN, expect.objectContaining({ status: CommunityOperationStatus.SENDING, - payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile', teamId: TEAM_ID }, + payload: { ...DEVICE_TOKEN_PAYLOAD, teamId: TEAM_ID }, }), true ) @@ -122,7 +127,7 @@ describe('QPSService', () => { it('stores UCAN in notification tokens store after successful registration', async () => { setReady() - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(notificationTokensStore.addToken).toHaveBeenCalledWith('test-user-id', 'test-ucan') }) @@ -131,7 +136,7 @@ describe('QPSService', () => { setReady() notificationTokensStore.addToken.mockRejectedValueOnce(new Error('store not initialized')) - const result = await qpsService.register(TOKEN) + const result = await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(result?.payload).toEqual({ ucan: 'test-ucan' }) expect(notificationTokensStore.addToken).toHaveBeenCalledWith('test-user-id', 'test-ucan') @@ -149,7 +154,7 @@ describe('QPSService', () => { ) setReady() - const result = await disabled.register(TOKEN) + const result = await disabled.register(DEVICE_TOKEN_PAYLOAD) expect(result).toBeUndefined() expect(qssClient.sendMessage).not.toHaveBeenCalled() @@ -162,7 +167,7 @@ describe('QPSService', () => { roles: { amIMemberOfRole: () => true }, } - const result = await qpsService.register(TOKEN) + const result = await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(result).toBeUndefined() expect(qssClient.sendMessage).not.toHaveBeenCalled() @@ -172,7 +177,7 @@ describe('QPSService', () => { qssClient.connected = true sigChainService.activeChain = null - const result = await qpsService.register(TOKEN) + const result = await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(result).toBeUndefined() expect(qssClient.sendMessage).not.toHaveBeenCalled() @@ -182,8 +187,8 @@ describe('QPSService', () => { qssClient.connected = false sigChainService.activeChain = null - await qpsService.register('old-token') - await qpsService.register(TOKEN) + await qpsService.register({ ...DEVICE_TOKEN_PAYLOAD, deviceToken: 'old-token' }) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) // Now become ready and flush setReady() @@ -196,7 +201,7 @@ describe('QPSService', () => { expect(qssClient.sendMessage).toHaveBeenCalledWith( WebsocketEvents.REGISTER_DEVICE_TOKEN, expect.objectContaining({ - payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile', teamId: TEAM_ID }, + payload: { ...DEVICE_TOKEN_PAYLOAD, teamId: TEAM_ID }, }), true ) @@ -208,7 +213,7 @@ describe('QPSService', () => { // Cache token while not ready qssClient.connected = false sigChainService.activeChain = null - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(qssClient.sendMessage).not.toHaveBeenCalled() // Become ready and emit connected @@ -220,7 +225,7 @@ describe('QPSService', () => { expect(qssClient.sendMessage).toHaveBeenCalledWith( WebsocketEvents.REGISTER_DEVICE_TOKEN, expect.objectContaining({ - payload: { deviceToken: TOKEN, bundleId: 'com.quietmobile', teamId: TEAM_ID }, + payload: { ...DEVICE_TOKEN_PAYLOAD, teamId: TEAM_ID }, }), true ) @@ -229,7 +234,7 @@ describe('QPSService', () => { it('does not flush when QSS connects but sigchain is not joined', async () => { qssClient.connected = false sigChainService.activeChain = null - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) // QSS connects but sigchain still not joined qssClient.connected = true @@ -245,7 +250,7 @@ describe('QPSService', () => { // Cache token: QSS connected but no sigchain qssClient.connected = true sigChainService.activeChain = null - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) expect(qssClient.sendMessage).not.toHaveBeenCalled() // QSS completes the join flow and the sigchain is now ready @@ -259,7 +264,7 @@ describe('QPSService', () => { it('does not flush when QSS fully joins but QSS is not connected', async () => { qssClient.connected = false sigChainService.activeChain = null - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) // Join completes but the transport is still disconnected sigChainService.activeChain = { @@ -277,7 +282,7 @@ describe('QPSService', () => { it('does not send twice after flushing', async () => { qssClient.connected = false sigChainService.activeChain = null - await qpsService.register(TOKEN) + await qpsService.register(DEVICE_TOKEN_PAYLOAD) setReady() qssService.emitEvent(QSSEvents.QSS_FULLY_JOINED) diff --git a/packages/backend/src/nest/qps/qps.service.ts b/packages/backend/src/nest/qps/qps.service.ts index 31fd893101..f63fa7326e 100644 --- a/packages/backend/src/nest/qps/qps.service.ts +++ b/packages/backend/src/nest/qps/qps.service.ts @@ -19,14 +19,19 @@ import { NotificationTokensStore } from '../storage/notifications/notificationTo import { QSSService } from '../qss/qss.service' import { JoinStatus } from '../libp2p/libp2p.auth' -const BUNDLE_ID = 'com.quietmobile' const PUSH_BATCH_SIZE = 500 // FCM allows up to 500 tokens per batch request const LEAVE_TOMBSTONE_ACK_TIMEOUT_MS = 5_000 +interface DeviceTokenPayload { + deviceToken: string + bundleId: string + platform: 'ios' | 'android' +} + @Injectable() export class QPSService implements OnModuleInit { private readonly logger = createLogger('qps:service') - private _pendingDeviceToken: string | undefined = undefined + private _pendingDeviceToken: DeviceTokenPayload | undefined = undefined constructor( @Inject(QPS_ALLOWED) private readonly qpsAllowed: boolean, @@ -46,9 +51,9 @@ export class QPSService implements OnModuleInit { } onModuleInit() { - this.socketService.on(SocketActions.SEND_DEVICE_TOKEN, async (payload: { deviceToken: string }) => { + this.socketService.on(SocketActions.SEND_DEVICE_TOKEN, async (payload: DeviceTokenPayload) => { this.logger.info('Received device token from frontend') - await this.register(payload.deviceToken) + await this.register(payload) }) this.qssService.on(QSSEvents.QSS_FULLY_JOINED, () => this._flushPendingToken()) @@ -59,10 +64,10 @@ export class QPSService implements OnModuleInit { /** * Registers the device token with QPS - * @param deviceToken + * @param payload * @returns */ - public async register(deviceToken: string): Promise { + public async register(payload: DeviceTokenPayload): Promise { if (!this.enabled) { this.logger.warn('QPS not enabled, skipping registration') return undefined @@ -70,11 +75,11 @@ export class QPSService implements OnModuleInit { if (!this.ready) { this.logger.info('QSS not connected or sigchain not joined, caching device token') - this._pendingDeviceToken = deviceToken + this._pendingDeviceToken = payload return undefined } - return this._register(deviceToken) + return this._register(payload) } public async tombstoneCurrentUserNotificationTokens(): Promise { @@ -277,7 +282,7 @@ export class QPSService implements OnModuleInit { } } - private async _register(deviceToken: string): Promise { + private async _register(payload: DeviceTokenPayload): Promise { this.logger.info('Registering device token') try { const response = await this.qssClient.sendMessage( @@ -285,7 +290,12 @@ export class QPSService implements OnModuleInit { { ts: DateTime.utc().toMillis(), status: CommunityOperationStatus.SENDING, - payload: { deviceToken, bundleId: BUNDLE_ID, teamId: this.sigChainService.team.id }, + payload: { + deviceToken: payload.deviceToken, + bundleId: payload.bundleId, + platform: payload.platform, + teamId: this.sigChainService.team.id, + }, } satisfies QPSRegisterMessage, true ) diff --git a/packages/backend/src/nest/qps/qps.types.ts b/packages/backend/src/nest/qps/qps.types.ts index 66c5434549..6e7a0c7422 100644 --- a/packages/backend/src/nest/qps/qps.types.ts +++ b/packages/backend/src/nest/qps/qps.types.ts @@ -3,6 +3,7 @@ import { BaseWebsocketMessage } from '../qss/qss.types' export interface QPSRegisterPayload { deviceToken: string bundleId: string + platform: 'ios' | 'android' teamId: string } diff --git a/packages/backend/src/nest/socket/socket.service.ts b/packages/backend/src/nest/socket/socket.service.ts index aefd7aeaa8..70a7f19ad1 100644 --- a/packages/backend/src/nest/socket/socket.service.ts +++ b/packages/backend/src/nest/socket/socket.service.ts @@ -239,9 +239,12 @@ export class SocketService extends EventEmitter implements OnModuleInit { }) // ====== Push Notifications ====== - socket.on(SocketActions.SEND_DEVICE_TOKEN, async (payload: { deviceToken: string }) => { - this.emit(SocketActions.SEND_DEVICE_TOKEN, payload) - }) + socket.on( + SocketActions.SEND_DEVICE_TOKEN, + async (payload: { deviceToken: string; bundleId: string; platform: 'ios' | 'android' }) => { + this.emit(SocketActions.SEND_DEVICE_TOKEN, payload) + } + ) }) // Ensure the underlying connections get closed. See: diff --git a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts index d6ea1539cb..237756a19c 100644 --- a/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts +++ b/packages/mobile/src/store/pushNotifications/pushNotifications.master.saga.ts @@ -1,6 +1,7 @@ import { all, fork, takeEvery, call, put, select, take, cancelled, delay } from 'typed-redux-saga' import { eventChannel } from 'redux-saga' import { NativeModules, AppState, AppStateStatus, Platform } from 'react-native' +import DeviceInfo from 'react-native-device-info' import nativeEventEmitter from '../nativeServices/events/nativeEventEmitter' import { pushNotificationsActions } from './pushNotifications.slice' import { pushNotificationsSelectors } from './pushNotifications.selectors' @@ -76,7 +77,15 @@ function* sendDeviceTokenToBackendSaga(token: string): Generator { logger.info('Waiting for websocket connection before sending FCM token') yield* call(waitForWebsocketConnectionSaga) logger.info('Sending FCM token to backend') - yield* put(pushNotifications.actions.sendDeviceTokenToBackend(token)) + const platform = Platform.OS === 'android' ? 'android' : 'ios' + const bundleId = DeviceInfo.getBundleId() + yield* put( + pushNotifications.actions.sendDeviceTokenToBackend({ + deviceToken: token, + bundleId, + platform, + }) + ) } function* syncCurrentDeviceTokenSaga(): Generator { diff --git a/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts index 2a91ed8bf1..4fae7362d7 100644 --- a/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts +++ b/packages/state-manager/src/sagas/pushNotifications/pushNotifications.slice.ts @@ -7,7 +7,14 @@ export const pushNotificationsSlice = createSlice({ initialState: { ...new PushNotificationsState() }, name: StoreKeys.PushNotifications, reducers: { - sendDeviceTokenToBackend: (state, _action: PayloadAction) => state, + sendDeviceTokenToBackend: ( + state, + _action: PayloadAction<{ + deviceToken: string + bundleId: string + platform: 'ios' | 'android' + }> + ) => state, }, }) diff --git a/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts b/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts index d59d176822..8c07f02da9 100644 --- a/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts +++ b/packages/state-manager/src/sagas/pushNotifications/sendDeviceToken.saga.ts @@ -7,8 +7,15 @@ import { applyEmitParams } from '../../types' const logger = createLogger('sendDeviceTokenSaga') -export function* sendDeviceTokenSaga(socket: Socket, action: PayloadAction): Generator { - const deviceToken = action.payload +export function* sendDeviceTokenSaga( + socket: Socket, + action: PayloadAction<{ + deviceToken: string + bundleId: string + platform: 'ios' | 'android' + }> +): Generator { + const payload = action.payload logger.info('Sending device token to backend') - yield* apply(socket, socket.emit, applyEmitParams(SocketActions.SEND_DEVICE_TOKEN, { deviceToken })) + yield* apply(socket, socket.emit, applyEmitParams(SocketActions.SEND_DEVICE_TOKEN, payload)) } diff --git a/packages/state-manager/src/utils/tests/factories.ts b/packages/state-manager/src/utils/tests/factories.ts index 11fea428ba..6b89d5bea2 100644 --- a/packages/state-manager/src/utils/tests/factories.ts +++ b/packages/state-manager/src/utils/tests/factories.ts @@ -677,9 +677,15 @@ export const getSocketFactory = async () => { factory.define(SocketActions.TOGGLE_P2P, Object, () => true) // Push notification events - factory.define<{ deviceToken: string }>(SocketActions.SEND_DEVICE_TOKEN, Object, { - deviceToken: 'test-device-token', - }) + factory.define<{ deviceToken: string; bundleId: string; platform: 'ios' | 'android' }>( + SocketActions.SEND_DEVICE_TOKEN, + Object, + { + deviceToken: 'test-device-token', + bundleId: 'com.quietmobile', + platform: 'ios', + } + ) return factory } diff --git a/packages/types/src/socket.ts b/packages/types/src/socket.ts index 61ec63354a..88e8c5a22b 100644 --- a/packages/types/src/socket.ts +++ b/packages/types/src/socket.ts @@ -214,7 +214,11 @@ export interface SocketActionsMap { [SocketActions.HCAPTCHA_REQUEST]: EmitEvent // ====== Push Notifications ====== - [SocketActions.SEND_DEVICE_TOKEN]: EmitEvent<{ deviceToken: string }> + [SocketActions.SEND_DEVICE_TOKEN]: EmitEvent<{ + deviceToken: string + bundleId: string + platform: 'ios' | 'android' + }> // ====== Misc ====== [SocketActions.TOGGLE_P2P]: EmitEvent void> From d3dc836ea802585bee5545c9afc1b7b5a24a4ad3 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 11:48:25 -0400 Subject: [PATCH 55/92] update qss pointer --- 3rd-party/qss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/3rd-party/qss b/3rd-party/qss index aadb8b5778..49e0e3e4cb 160000 --- a/3rd-party/qss +++ b/3rd-party/qss @@ -1 +1 @@ -Subproject commit aadb8b57783e10197fb64dab32e9bfe3e28fa866 +Subproject commit 49e0e3e4cbb527560fc8b022c6c72d14a4500a6c From 6ec66c16a76cc40398fe873f6833a0d4a24f4109 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 12:07:34 -0400 Subject: [PATCH 56/92] Publish - @quiet/desktop@7.1.0-alpha.0 - @quiet/mobile@7.1.0-alpha.0 --- package-lock.json | 2 +- packages/desktop/CHANGELOG.md | 28 +++++++++++++++++++ packages/desktop/package-lock.json | 4 +-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 22 +++++++++++++++ packages/mobile/android/app/build.gradle | 4 +-- packages/mobile/ios/Quiet/Info.plist | 24 ++++++++-------- .../Info.plist | 6 ++-- packages/mobile/ios/QuietTests/Info.plist | 4 +-- packages/mobile/package-lock.json | 4 +-- packages/mobile/package.json | 2 +- 11 files changed, 76 insertions(+), 26 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c15e53ad5..1cde8dbc93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "typescript": "^4.9.5" }, "engines": { - "node": "20.20.0", + "node": "20.20.1", "npm": "10.8.2" } }, diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index d91d62eff8..fa68e76421 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,31 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.0](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@6.1.0-alpha.3...@quiet/desktop@7.1.0-alpha.0) (2026-04-17) + + +### Bug Fixes + +* **3140:** Validate qss endpoint when qss is allowed to avoid registration loops ([#3141](https://github.com/TryQuiet/quiet/issues/3141)) ([38d4292](https://github.com/TryQuiet/quiet/commit/38d42923dee8c31c454ca8a107479f9db1bf3bdb)) +* **3146:** Fix slow electron startup ([#3147](https://github.com/TryQuiet/quiet/issues/3147)) ([748374e](https://github.com/TryQuiet/quiet/commit/748374e0d43124f2c95de15f38732145310adb56)) +* Better QSS handling in invite links, proper redialing on disconnects, resetting LFA join status when in intermediate state on disconnect ([#3012](https://github.com/TryQuiet/quiet/issues/3012)) ([2b68c3c](https://github.com/TryQuiet/quiet/commit/2b68c3c94693f2aafa7fc5c7a65073e92c93e6dd)) + + +### Features + +* **2803:** Write orbitdb sync data to QSS ([#2914](https://github.com/TryQuiet/quiet/issues/2914)) ([bfbfd92](https://github.com/TryQuiet/quiet/commit/bfbfd925feb35abf4bb6f04d2ed27b08a58b14cb)) + + +### Reverts + +* Revert "Use mise-en-place (https://mise.jdx.dev/) for repo dependencies" ([18c8d31](https://github.com/TryQuiet/quiet/commit/18c8d31ea982fb8a793ab59a196cf00f2fc39f90)) + + + + + # Changelog [unreleased] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index a2edbccfe4..3e0f9e43ac 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 702df597dc..759dcfd465 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index d91d62eff8..fe3bd4b8b0 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,25 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.0](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@6.1.0-alpha.3...@quiet/mobile@7.1.0-alpha.0) (2026-04-17) + + +### Bug Fixes + +* Backend fails to start on GrapheneOS in 6.5.1 ([#3106](https://github.com/TryQuiet/quiet/issues/3106)) ([ce4b9f5](https://github.com/TryQuiet/quiet/commit/ce4b9f56a3cb3c1cd9836e3f234bb3b44560f396)) +* Keyboard avoiding on android, properly displaying send button on android, newline rendering in message component on mobile ([#2980](https://github.com/TryQuiet/quiet/issues/2980)) ([4f0afa3](https://github.com/TryQuiet/quiet/commit/4f0afa30160cdc75f624e7246580438c7d2e7600)) + + +### Reverts + +* Revert "Use mise-en-place (https://mise.jdx.dev/) for repo dependencies" ([18c8d31](https://github.com/TryQuiet/quiet/commit/18c8d31ea982fb8a793ab59a196cf00f2fc39f90)) + + + + + # Changelog [unreleased] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 540758ff0c..58b7fa8afb 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 580 - versionName "7.0.1" + versionCode 581 + versionName "7.1.0-alpha.0" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index d3c26d4d7c..c26979e75b 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 7.0.1 + 7.1.0 CFBundleSignature ???? CFBundleURLTypes @@ -34,25 +34,25 @@ CFBundleVersion - 533 + 534 ITSAppUsesNonExemptEncryption - + LSRequiresIPhoneOS - + LSSupportsOpeningDocumentsInPlace - + NSAppTransportSecurity NSAllowsArbitraryLoads - + NSAllowsLocalNetworking - + NSExceptionDomains localhost NSExceptionAllowsInsecureHTTPLoads - + @@ -61,9 +61,9 @@ NSDocumentsFolderUsageDescription Quiet uses the document directory for storing files and images sent through the app. NSLocationWhenInUseUsageDescription - + NSPhotoLibraryLimitedAccessAPISupport - + NSPhotoLibraryUsageDescription Quiet access photos for sending images through the app. QuietKeychainAccessGroup @@ -90,7 +90,7 @@ remote-notification UIFileSharingEnabled - + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -104,6 +104,6 @@ UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance - + diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index cb69f36402..d1759d9d05 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -17,11 +17,11 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 7.0.1 + 7.1.0 CFBundleVersion - 13 + 14 FirebaseAppDelegateProxyEnabled - + QuietKeychainAccessGroup $(DEVELOPMENT_TEAM).com.quietmobile NSExtension diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 4949ce6b15..4a7478f2a5 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 7.0.1 + 7.1.0 CFBundleSignature ???? CFBundleVersion - 527 + 528 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 3549695303..476241b0c0 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 0d6764b0c6..1135dba39b 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.0.1", + "version": "7.1.0-alpha.0", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From d0f9d246154fc6a58f8522e333be208c61e2325d Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 12:07:50 -0400 Subject: [PATCH 57/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 78 +++++++++++++++++++++------------- packages/mobile/CHANGELOG.md | 80 +++++++++++++++++++++++------------ 2 files changed, 100 insertions(+), 58 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index fa68e76421..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,70 +1,88 @@ -# Change Log +# Changelog -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [7.1.0] -# [7.1.0-alpha.0](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@6.1.0-alpha.3...@quiet/desktop@7.1.0-alpha.0) (2026-04-17) +### Features +* Adds ios push notification support [#3087](https://github.com/TryQuiet/quiet/issues/3087) -### Bug Fixes +### Fixes -* **3140:** Validate qss endpoint when qss is allowed to avoid registration loops ([#3141](https://github.com/TryQuiet/quiet/issues/3141)) ([38d4292](https://github.com/TryQuiet/quiet/commit/38d42923dee8c31c454ca8a107479f9db1bf3bdb)) -* **3146:** Fix slow electron startup ([#3147](https://github.com/TryQuiet/quiet/issues/3147)) ([748374e](https://github.com/TryQuiet/quiet/commit/748374e0d43124f2c95de15f38732145310adb56)) -* Better QSS handling in invite links, proper redialing on disconnects, resetting LFA join status when in intermediate state on disconnect ([#3012](https://github.com/TryQuiet/quiet/issues/3012)) ([2b68c3c](https://github.com/TryQuiet/quiet/commit/2b68c3c94693f2aafa7fc5c7a65073e92c93e6dd)) +### Chores +## [7.0.1] ### Features -* **2803:** Write orbitdb sync data to QSS ([#2914](https://github.com/TryQuiet/quiet/issues/2914)) ([bfbfd92](https://github.com/TryQuiet/quiet/commit/bfbfd925feb35abf4bb6f04d2ed27b08a58b14cb)) +* Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) +* Adds push notification service [#3086](https://github.com/TryQuiet/quiet/issues/3086) + +### Fixes + +* Fixed bug around killing old tor process that results in an unhandled exception [#3135](https://github.com/TryQuiet/quiet/issues/3135) +* Adds new mac entitlement to fix arm64 crashes on arm64 binaries [#3180](https://github.com/TryQuiet/quiet/issues/3180) +* Fixed bug that caused a registration loop when QSS_ALLOWED is true but the endpoint is unset [#3140](https://github.com/TryQuiet/quiet/issues/3140) +* Fix delay between sign-in and historical log entry pull from QSS [#3127](https://github.com/TryQuiet/quiet/issues/3127) +### Chores -### Reverts +* Upgrades NodeJS to 20.20.1 -* Revert "Use mise-en-place (https://mise.jdx.dev/) for repo dependencies" ([18c8d31](https://github.com/TryQuiet/quiet/commit/18c8d31ea982fb8a793ab59a196cf00f2fc39f90)) +## [7.0.0] +### Features +* Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057) +* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) +* Use LFA-based identity in OrbitDB +* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) +* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) +* Store user metadata in IOS native storage for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) +## [6.6.2] +### Fixes -# Changelog +* Fixed crashes on GrapheneOS devices -[unreleased] +## [6.6.0] ### Features * Adds hcaptcha verification for protected QSS actions [#2908](https://github.com/TryQuiet/quiet/issues/2908) -* Add ability to adjust image/file auto-download size threshold [#3019](https://github.com/TryQuiet/quiet/pull/3019) * Messages can now be relayed using QSS [#2805](https://github.com/TryQuiet/quiet/issues/2805) * Messages can be retrieved from QSS stores [#2806](https://github.com/TryQuiet/quiet/issues/2806) * Profile photos are now uploaded via IPFS [#3048](https://github.com/TryQuiet/quiet/issues/3048) -* Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) -* Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057) -* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) -* Use LFA-based identity in OrbitDB -* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) -* Adds push notification service [#3086](https://github.com/TryQuiet/quiet/issues/3086) ### Fixes -* DisableWebDrag added to links listed in an issue [#481] (https://github.com/TryQuiet/quiet/issues/481) * Fixed being unable to quit application during initial load [#3046](https://github.com/TryQuiet/quiet/issues/3046) * Fixed trace logger toggles [#3045](https://github.com/TryQuiet/quiet/issues/3045) -* Fixed auth issues when using QSS in AWS [#3128](https://github.com/TryQuiet/quiet/issues/3128) -* Fixed bug around killing old tor process that results in an unhandled exception [#3135](https://github.com/TryQuiet/quiet/issues/3135) -* Adds new mac entitlement to fix arm64 crashes on arm64 binaries [#3180](https://github.com/TryQuiet/quiet/issues/3180) -* Fixed bug that caused a registration loop when QSS_ALLOWED is true but the endpoint is unset [#3140](https://github.com/TryQuiet/quiet/issues/3140) +* Handle AWS QSS endpoints in invite links [#3024](https://github.com/TryQuiet/quiet/issues/3024) + +## [6.5.1] ### Chores -* Change autoupdater text [#2971](https://github.com/TryQuiet/quiet/issues/2971) -* Fixed issues with testing workflows [#3030](https://github.com/TryQuiet/quiet/issues/3030) -* Add MacOS arm64-specific build jobs to resolve slow UI startup on Apple Silicon [#3146](https://github.com/TryQuiet/quiet/issues/3146) +* Updgrade NodeJS on mobile to 18.20.4 +* 16kb compliance changes on android + +## [6.4.0] + +### Features + +* Add ability to adjust image/file auto-download size threshold [#3019](https://github.com/TryQuiet/quiet/pull/3019) ### Fixes -* Handle AWS QSS endpoints in invite links [#3024](https://github.com/TryQuiet/quiet/issues/3024) +* DisableWebDrag added to links listed in an issue [#481] (https://github.com/TryQuiet/quiet/issues/481) * Fixes dialing on join when using AWS QSS [#3025](https://github.com/TryQuiet/quiet/issues/3025) -* Fix delay between sign-in and historical log entry pull from QSS [#3127](https://github.com/TryQuiet/quiet/issues/3127) + +### Chores + +* Change autoupdater text [#2971](https://github.com/TryQuiet/quiet/issues/2971) +* Fixed issues with testing workflows [#3030](https://github.com/TryQuiet/quiet/issues/3030) +* Add MacOS arm64-specific build jobs to resolve slow UI startup on Apple Silicon [#3146](https://github.com/TryQuiet/quiet/issues/3146) ## [6.3.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index fe3bd4b8b0..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,64 +1,88 @@ -# Change Log +# Changelog -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [7.1.0] -# [7.1.0-alpha.0](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@6.1.0-alpha.3...@quiet/mobile@7.1.0-alpha.0) (2026-04-17) +### Features +* Adds ios push notification support [#3087](https://github.com/TryQuiet/quiet/issues/3087) -### Bug Fixes +### Fixes -* Backend fails to start on GrapheneOS in 6.5.1 ([#3106](https://github.com/TryQuiet/quiet/issues/3106)) ([ce4b9f5](https://github.com/TryQuiet/quiet/commit/ce4b9f56a3cb3c1cd9836e3f234bb3b44560f396)) -* Keyboard avoiding on android, properly displaying send button on android, newline rendering in message component on mobile ([#2980](https://github.com/TryQuiet/quiet/issues/2980)) ([4f0afa3](https://github.com/TryQuiet/quiet/commit/4f0afa30160cdc75f624e7246580438c7d2e7600)) +### Chores +## [7.0.1] -### Reverts +### Features -* Revert "Use mise-en-place (https://mise.jdx.dev/) for repo dependencies" ([18c8d31](https://github.com/TryQuiet/quiet/commit/18c8d31ea982fb8a793ab59a196cf00f2fc39f90)) +* Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) +* Adds push notification service [#3086](https://github.com/TryQuiet/quiet/issues/3086) +### Fixes +* Fixed bug around killing old tor process that results in an unhandled exception [#3135](https://github.com/TryQuiet/quiet/issues/3135) +* Adds new mac entitlement to fix arm64 crashes on arm64 binaries [#3180](https://github.com/TryQuiet/quiet/issues/3180) +* Fixed bug that caused a registration loop when QSS_ALLOWED is true but the endpoint is unset [#3140](https://github.com/TryQuiet/quiet/issues/3140) +* Fix delay between sign-in and historical log entry pull from QSS [#3127](https://github.com/TryQuiet/quiet/issues/3127) +### Chores +* Upgrades NodeJS to 20.20.1 -# Changelog +## [7.0.0] -[unreleased] +### Features + +* Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057) +* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) +* Use LFA-based identity in OrbitDB +* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) +* Store LFA keys in IOS keychain for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) +* Store user metadata in IOS native storage for notifications [#3091](https://github.com/TryQuiet/quiet/issues/3091) + +## [6.6.2] + +### Fixes + +* Fixed crashes on GrapheneOS devices + +## [6.6.0] ### Features * Adds hcaptcha verification for protected QSS actions [#2908](https://github.com/TryQuiet/quiet/issues/2908) -* Add ability to adjust image/file auto-download size threshold [#3019](https://github.com/TryQuiet/quiet/pull/3019) * Messages can now be relayed using QSS [#2805](https://github.com/TryQuiet/quiet/issues/2805) * Messages can be retrieved from QSS stores [#2806](https://github.com/TryQuiet/quiet/issues/2806) * Profile photos are now uploaded via IPFS [#3048](https://github.com/TryQuiet/quiet/issues/3048) -* Registers APNS token with push notifications service [#3080](https://github.com/TryQuiet/quiet/issues/3080) -* Create an invite lockbox when using QSS [#3057](https://github.com/TryQuiet/quiet/issues/3057) -* Self-assign the member role when joining with QSS [#3058](https://github.com/TryQuiet/quiet/issues/3058) -* Use LFA-based identity in OrbitDB -* Requests iOS notification permissions when app launches [#3079](https://github.com/TryQuiet/quiet/issues/3079) -* Adds push notification service [#3086](https://github.com/TryQuiet/quiet/issues/3086) ### Fixes -* DisableWebDrag added to links listed in an issue [#481] (https://github.com/TryQuiet/quiet/issues/481) * Fixed being unable to quit application during initial load [#3046](https://github.com/TryQuiet/quiet/issues/3046) * Fixed trace logger toggles [#3045](https://github.com/TryQuiet/quiet/issues/3045) -* Fixed auth issues when using QSS in AWS [#3128](https://github.com/TryQuiet/quiet/issues/3128) -* Fixed bug around killing old tor process that results in an unhandled exception [#3135](https://github.com/TryQuiet/quiet/issues/3135) -* Adds new mac entitlement to fix arm64 crashes on arm64 binaries [#3180](https://github.com/TryQuiet/quiet/issues/3180) -* Fixed bug that caused a registration loop when QSS_ALLOWED is true but the endpoint is unset [#3140](https://github.com/TryQuiet/quiet/issues/3140) +* Handle AWS QSS endpoints in invite links [#3024](https://github.com/TryQuiet/quiet/issues/3024) + +## [6.5.1] ### Chores -* Change autoupdater text [#2971](https://github.com/TryQuiet/quiet/issues/2971) -* Fixed issues with testing workflows [#3030](https://github.com/TryQuiet/quiet/issues/3030) -* Add MacOS arm64-specific build jobs to resolve slow UI startup on Apple Silicon [#3146](https://github.com/TryQuiet/quiet/issues/3146) +* Updgrade NodeJS on mobile to 18.20.4 +* 16kb compliance changes on android + +## [6.4.0] + +### Features + +* Add ability to adjust image/file auto-download size threshold [#3019](https://github.com/TryQuiet/quiet/pull/3019) ### Fixes -* Handle AWS QSS endpoints in invite links [#3024](https://github.com/TryQuiet/quiet/issues/3024) +* DisableWebDrag added to links listed in an issue [#481] (https://github.com/TryQuiet/quiet/issues/481) * Fixes dialing on join when using AWS QSS [#3025](https://github.com/TryQuiet/quiet/issues/3025) -* Fix delay between sign-in and historical log entry pull from QSS [#3127](https://github.com/TryQuiet/quiet/issues/3127) + +### Chores + +* Change autoupdater text [#2971](https://github.com/TryQuiet/quiet/issues/2971) +* Fixed issues with testing workflows [#3030](https://github.com/TryQuiet/quiet/issues/3030) +* Add MacOS arm64-specific build jobs to resolve slow UI startup on Apple Silicon [#3146](https://github.com/TryQuiet/quiet/issues/3146) ## [6.3.0] From 8c0775f5db625cd44d5eeb0e9f909da0a0ebd518 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 18:44:48 -0400 Subject: [PATCH 58/92] update provisioning profile --- ...ppStore_comquietmobile.mobileprovision.gpg | Bin 7931 -> 7970 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg b/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg index 0f351e11e55385cf58c7b3b029ceca741a5b9544..498db5118ddd95998e03a399c00bc1a90a92fe69 100644 GIT binary patch literal 7970 zcmV+-AKl=L4Fm}T2nZIu(t~NeNc__30XHbIA(l0@Mqx9cXTON1ad$DqX|SaFYR-*6 zYOW_PHcZYsy7t>Dwon7Z;BYYU;Gq@+>LSwb7<(KZhB65}>om(@o48M#dirr4YQ8b= z!~a~yRHI9<`u}IQT0VMh5D|b$TZlYt?G?M5Jo0)H1pS!$!+_zg_92H%1fbnhk0M-P z*eyFT&j^~%CFRTu!R>@P&>jmsMi9Z z^mKwcUuLYz-{#f`ia(%W9M)ylkhm2~oMpb#wQxINegsUjd02byzVd&#R|C9(2e_t$ zl-%o}cXrF;)8=~d*pY4)ZvmA%ToMr(jFPu9YiBWfsFJVtq5KyBDiOfc68R9Ip%ONG_B#zlq<` z#k%rxzEE8$w(;1@PL7VoT7r{c zpPw9gYR${JU1Je*8{1jntq1jklvzpm_Md#=kKX$9BJ2vekvQqKr*)PxZ)QhjaKU1+ z$;ZPYDloS8xMOAtyq+>CSsNpMb|2O7A$0hnZ)Pc8-2FADOaZqBg@zMQE!B-i>C_nS ze)7DC=d3eI&ve!QUgkrS|NI7{S0zVe1hThYg4PwlxIrPc1mCEx^p=_r)lwxI@rt|8 z%AlX>KtjV+(GfSc*(k|vzQvO`-m(9Lb578YwnX_=ohyvlgNrS77n|9 zr%ki|&v7K>>I$TSy)Wo35qy17N{T>1DeWA%>)412+G3;BHU%P_IKY_<^Mg{?4{`;I$9$}8=x9cE|U{P^&`;z z-hyrKoiQY zj9dieC1+-Ib2d=XWz)OpPMMX4d_zU+ya;$W7+sBiA%qgR0Yd3VC$=eUD86)LN=E-3 zzV}el#7tLP7}q|Oh`7H!1&{LRHO`=rzYs)^4?-q?H316!_A^P${XioDy2Ss7fI|VB z`2v1OvYco7-NwK>!KmNjmVIUEU)M00*x@Ey%?OF2w^c7FE&9!mle7<&(7!#$xEHC^ z0t=_Z9$h3+=BXf+@1&}JA%AyYq80k=;A07Sy{Y)6--v4279F1uMCZ=1G7!6urYs~1 z?(qm#I}OHxY~`Pk6O>)PbCMeiN-@RKl3(3a5O^YE7~Xss3!MghZ24~2z#{(a^x6i7 z`;HB%2=Q|d_35#==`fz&97Y?PApuNheNhc010ECvp%io}PAbnvHom%F?_Qk{KS^*# z3=?MpKsh^s;U0$adQ^DGraTj<9;wC4SMF7k*L$tFAIXj&>aBD_>DPu*H~T~HHB z**r|uYxtv==-9R19Z*A!e7njj549A0m^=7on=B#c-eZglAOqYj!j(D7PVs2&`~}PCWYx6; zgj9#evJS+<UzV_tBJblnasPIq~?lM9YcJ}zrVAm)WC9P)8 zZsfwKF`MShRaFN*{zI}|rdQ$c6@Z#W1=pIUmbbXsKR>UR66tOVeoqYiV3T%hG77#3#e{~;* zF8uzzs{XUyn=5k6-&UmJYR`Pf&C&IkZ;a$AUG~4VKzBqo@ToQwSH6R~Z7;K!&pM!~ zxa~gTQfxTwvunD&seCi^Mz^aolGeyLxhp6pLf%fig|eBl;+-SNx`k}?@Tt1w+~$%Q z;ed{zr0`C&|K}Ff0R44caF1R_9ZT`SPCp4aIylKfYnWX^YHH)=v$YJ=4`&m2Yn}6> zO<_QA$qs--=|JN0~UeGQ&0n>WusXV>dyDfd!b_vdv`c7$L zoHpmezT=mUB{spF^Q_RJ-takda3x$ujQpNYw=dbY;3=k;ymygeHK|69eRjCEM}Kk- zZbrk+e?MrUL;VKU;vUIc8lG60jC*p?-1>k6gcDQm#vAkwVsRLkKmK&luDsen%s2OY z_Cl_yJXfX8lk|CH26kDzVk zTfhfbo*Ru3JEu$M>moQTUXyl0x2^WNvjV&CS)Jj3pkO4frn_Fyhkmc84EWJ_F~NF9H+g)c5IHpemJZvu8mXkaFCMJ5 ztw~XJKW5)Y3HPZiMZ{-_K}KXd%|#%nv5#{oxkJ8uW*%z{ho(nmTgwFa8nS`yjBWT1 zFebux<#V`Ykgj)>zcwdhE^m!X@GXwrY@mbCels2y9Os`z#juBnc7{(_7bvtHM_Jy9 zCM8XWF-AHpFV?l0(rxocQrPp|fo4#jKB0Xhi@wCHNpZ3MjP zdyA}Yoe6$;4~BuTfuQ{F^g6Q9M-f8#s_ym?&?|`{afXXT0&mdT-G9#P z6VlXm9~Ndx!RbSwzae(V4maT$+@nC|qwXs=`vwpO1T-XvG^}QKss;rE7>EL7 z8yxCD#P`Mic!@T8;Gvi#`AKG2>BcGH(>EjDgc^cHr-Xjr3}tT^n7FCE&%#V@>zT@i_;FN=%f>VdDNo@*|QTZzLRl8ya zhgznE?yt3A!ONLPf`f$6^?71_bZIiaE&Y7cUzS7{6(iTeLHVjbCBWuZn)H0yS7e!r$>$~kND>ATWz(zP&R+rn|ojGQsM4fN77A)0erxze++mG-db9pR)I@HTy zt2eY^^sZ2ve-ChREQ3)ySxMeNU_~`7sY^eSLnUHzqEnZ3Iqyg@AIT^og|^g1ROq+zv_)t#q1eZG zSH3qX5UnPCZJ#`>r7c@JFaaa_+%R!}Ue{O_)J$iG(PVpN+cWE&$#En)CIOT3_=uCS z-7A8Wf@sF9*Sgnw#>$deZoVDutEGlk_nPh)DzTDCsBm+5dX6$ra*BxaAtv}<`9@h2 zLP+P@3rRv|;b(pWOI6TpCd=e14rIJ%{pf{ObVj&pQhBX+=B#9@V%{>gbrENN{e4qX zLsk$K-Gbghqj2){E@`V@76l{lrN76Ei|%!$fPd{nxvcj_26wC2YETADfZ}u8g6wL0 z9L>ed(%lLYp&TmwL(y<_Z^Wwzrb{5E!IQx;`>2B+WREG#qby}`ZG7`apt;H9SaVF= zxkWTQFPwGdrZ*5{<~Ca-)z&PWv($NCLYi1H@}ESgF=PZpy$Tj6i8`7QG>9UgAQ>Ez?%R$h*y(#~a(CQ9B)?G$ayN)FkS$iw%f!6JPaF&3Y&SdXhI(bEiZI&BFubUWO4y>$- zw4_<+In!BcS{p-=or7>z-?^7qvtaB*Lt#^Wc<~SCMTWe<$A=JaOUi@7mPCsBG4`fW zP=UrQDR;w^?Q)u%LqG%iqP8nSJjnW%G_u*$>KTE_YWoWK8eI@bPK_1G!lv9vWfILR zA>4smkf0e?Ng6Jrcfeh(m-)#V2_k>o6KI@G?o3a`2e^T=I!uND>I6ScPa4rw!ur+& z?|e|1T6YDM&#;IMl&0$W*Sm6*eHdSa+@ZGYxh>Zc!*ZVjCEhCJN;{6XnYG(=1Y@>l~yjcq!2@vnHOD60@3iFl|JeV#$s(i*6|rhzZHqV>a(=hK{| zK8Q!NY6Eek__PK~X-c5BJ7-C`bf7QJRwZmj!4^7mPM z-a@Pb`>rI`hG{Lt%Y%%)oAeKho`-o{Qk8rj2z%;rDBq8p&cuv6D|i`c&ew(>G$gyi z<=5fay&~N;B8{Ut!~(g(rhoXei@+>lZIoPJFIF4$YPhl&jJw95tr|+h#(FyVkiFyc z{&04*W?ZgllI8q+91+!dxot|>w`GsUIIAwRFc`(%A{rt-tn8>Y9z^ynF-~y? z%V@OwT(k%StCKE;!;CWMy2pnu&&HIb2u!9_wgj*B!3CmAfRQ8mc1DHEjVI%ibSt^f z72M+VaAJ7wT1hK$@xx6%7#n&M3B9LQU!fgm@#4f!#aDFHs}49P1Zd5N#T^sp1qygm z$Y>CH>u$=Z+G7+|9!-Xg~8}`$#^F`LmV3`zArCw>xgm#&}IQX%41Zm<)dvl`8@bf zIq_P+@@yS321vrH13s%>8^9Lo%%>*cDy4 z59^?LM_dv4_Wth+9>)(<*M952k_YJBm1?(mv0qZpNj2|%6dc>n9h>?YRtGiy^HA*^ zZW0Zi{GkvH2xtD$Scr&tg!whZu8D!r;LfCA>LCLJ0Wzn+K+LM9HK z;=We!?7Aw`E-2+b5viB83j<6Z+X|9aCrsVd_BOd$^@f}ZcNHqqx@L$PF2c3 zHqs+&8af9ABVqh}%i`AbfYgnhJQHsR{x}OofMjDt(wY$K{%K85BDjy*S4+@nGP=-P z-IT<>)7}j^1s>zSxJGbqOt|>EAf^F`eKO_)UuXcJpZNUMJq(R5cm5`y*`;s4m z`qo0vgO4<5s;GYZ%PCn^-q9>=SGDBZevB9NPRh&e3Dgg8@*m4Zu~=4UYGb=ZAMQ1XhjA=_q5}5=rGhsI zolq~3tj&mr5apBK4KS0|M7e;trfg+6h{PkdG=-V2!857SiwRqhHZ7fUx8XIq@&`EZ zE#bE=QFTl#qLg1c&leTjA{XM)H#nf|AiFn&(-YQP`#;6v&Ij7&hiRxLTJUiglZll zI|0*r(5eW6{J`{OggK5S&F@D){3T5C{Cj}hNzTJbr>$cW;fXJA|B4Cj*Apn}4Rvb&B&Gl#*@LGUvq%>PClH)zh2 ztQ+cYSmK--A1c3gen}oeJlw;N+Hf9M4^P&V* z{n@f$GS)mJ$&s?V4+}o-c7-j+m>?z)F_k*-0h#(L<7$3>2n)$Nl&P&l00qNJ{s=w zw#3L$Fst5Z@~7(g!(5fY7`WWawq4n<{GF5boy_AqaHDUoOEuB1K@I{oxX(83e8kAQiIc=hmUd~Swnu;%Pj+ZI};&MquF_;>F+%$cj=%8Spb_BRDaAF4Oi4P5uo z?sEI)f&tW`l$gy2A=uUrgWg}+Epeh3{R}&|0t;G=gFl)v5BCG9``WuJvqtp}G>0s`9|N!m=fFhyE9Hl{i0`luwW+v4*LV{($r51zDJAXurhA7?lKJmR?dY>%-`M7M z#}S|S3`WQvAGQB0Tu2tAsoc~<$6mh0O_snfj5*0)xo~E)lSAlPsv(4cT*k)aR}oQG z55627T8`}fxlo=haQ^RXsO2mp7G=LbpGUt1%}8UsRDi-zPP;z=CD3!3WJ+Xe@C35P zeEwx3(4dMON8nan zLMCdleT)5`d_E?NEW&4NpUyYJi z@FBnyI_w&lU7MUvsvTbyl96i)zTfS`kAaWLbo2qlwxWLN!JS@r1r2_yc&^Oc{NaK8 zsn{YJGkvTVvTKy=Qa{SK&WtPR*z1Pv6N;*8MQ0;;C0&M4ZVO|;Mf-vRX1xqE3?+l> z_G+`N`g)UgbgDfW@)Tt#2z;iq(p-Don0U=0pd25_!GJ1jN-QNJ?)3m4(x?o~I~9C! zuX$XFU#n4a@S;1%E?`=+dVPVS+~d)uqDewydo<*>$Lv9I4krROYnOUxEy{cj6US&j z4}HkstSQrb(MSD3UXPC~3xdF>;~FE`%qLjw8rJ9Fu{yLTDbUID$C5FN+lnc8Q91t&OU`eQC!s*o!*34-4+CPY$ zJ)h(3qwN{KLmRE@0oX=!vPs%{Pt6Y=N@20`I+02FFg!np)Y`?RH{Ls16Qkz|VSxo+ z`)KfI$5K=O!+;kL+_8mnFI%i(vQW+?^cV+j3wcNYC~6?;EpzuN;bXkA*NCo{kcdrx zq7;U|A%bhR4UoBj;Br{lP`9BAT{PI81Gy05*KHAR7}bne?9|i96|_vPY!KjWSYn=D zza+HiU+S3_f0p#%RUbkv5Gd@6Kk z$OO>9_mq#f%v+s*z)OvmBQTDK(r3t9Cx|Rpc#3s@pYmRE;v`hu%P_0h@r?{}s%X-~ zulQRby;J)knUdhDzZ3${1o}PuBQtEX>?SxJnv^+82pG>H*L);d_v;~R!%&ToUBxYD zdyVsETj(BYb>uYD2kppE)7Fr6&fq%UT~G0}H{4+k?&fCz3l6on}axn_(pabq1~U Y9glqJiPbKj!f_xgM_q(tr#u4!15v2{Tb^p@p0Z2PFd=x%Kt3fLWqfP)cmxR+AC<^C3j&fSp zbft*KOtTT|k>T!RV$JO~6Pgg77~jSLjEv?mh^sKwRvmZ{3veR5q`K<2Ki6gOM*=j3 zwr@r=-fVrf>`@*XcZakoblZ=eNS{c7^hdl?dUGP-`;eMd1&+UI8=Ph3Gv{|x9Ai2nH zs`sr%2YRTBf`YJMu*}i>0R_Ce2I>T@t#w4XaYi&%SOqww5NUEq_x{?yetOS5YCUk= z#2qLLnW39hddm0<*niR<$rh!afDoyjK+ou0WKN907V}ZL$&t*Ol|}`<74A5-2vJ~O zC+aMX1xbbR{?nM9-(QJz&)vZwM|5ypOSlE6C>mtuYmP;xpdz_kEIbRnz*e?@8?ytZ zae2f0Y>58~c%;3n(%bK^BA`%oN8gNVmVJ=uMm0Tt&mP?PSFC~IoOab+`^nER_Povb zoSA8LTMI7Mb(+5l@6+02d0F`s=AhL_J2}6{-tkJl;EL1XUGVhC_>hukD>g4w?(-X`_VMZo`bIX-Om6-OpgR{ge3vR#-H}TAjsIaixM_Mf_85_ZBLQpi+tlml_q(~181V#Nd1mdNS46=x ze2yKJo1=Z+)4sR4vF1GyfZyF3eQ+A3M>PxTTwSBgRe~~vWWwBn#huwNtLS4{n_K79 zC0%pg0mj&cv50)se7-M?!`5HeGvbkGZ=>5FYZ+;zn&GM%I#7}h7xr2%oM#JMPuXij z;W^AIGG9?_Tf&f9u~I75_Gtt6cxex^{!y zC%u9;XobiVbfm#*$L1vHq@fb;a|Th_)t}0^nWuUNK1`N0RZkJHVbPIr?^}Ddq2q8~ z=>8pkHvV8xq02{#X{r#|Q^KpI%T{{2c70$2;h>z=D-baGz+nJk%}sCB1NW^qAeZBM z?|)Pp62*X(ASq_PeUSU9$`EK9ad+25Q}c1xv{}yVBy;k*kD#96VZQ7Z93tViD!L{K z@l6e9jhqCFS#wcpXtjlauQm<2yAhu*Z-rc=DE?hOO{1tfsbvDEzY4KdokE;iw0Cjf z+l=SA!hV>_>aGszUhjsqPab?ok4928+s#zlVZGHr_K-Y#=4{PU-0dg46D^yVB3g7X zc%FlINVg9md}dw@*EssFJNIzw!wjEbJy=PSPd2J7_cV2xl*ZQ`nxmwH2tB7z@fvFE zikT!BJ^pX-Jr4@%ov`o$`(x!+n|F#b5eiLY681^L*)wLI)QuTdCtfAvY+xNwg@zzX zRab+untmTRAj-8@FX6tiuLUY_iCgN|j57#WT zj!Ky_35)Z)BWqMP=EDfnohY9j7eVl)A~1b|*>2O)*H6qba0#sWc&7vSA8@}jX9}%F zctR?AcBW)|5xKO^jvA&fU{}i)N$FG^q`>omq6O({(cG-kB@2d`W{S!jN??;+8#OjA z-ul&2+$G2*d`Wl+RC(!fhHz-|EyWW@cbas{ul_p$GmTo2V1K8-N+D7xewagq+uCAlo5Sd7w7M z1DX00M{-AK8B#$nS7AiV&&%VCLcvnVvO;Nj zME_v+%9`fwDAs)K&wlZI63;Q`wc;uiBZeF67fb?KL!JBLwck(;NJMKlZm$fcK?0b9;Dp+-e6;dY zkeY6+p$UShku z`1kEnv~5_>2Q(3#zeF~YQLI#$$>X!}8v1vTjO8Nyw@a9m;^mr~lcMk1!-JfOHWng_ zka@x^9iw+)Z_8g{KgHnee+2}^Q_7|zy7sMP{CMrot5`NxsHtI~Eef49Lsv-ilh0yE#=O5vx5AA~7 z#2K`6TjS0L04%tC@jFb}os7gG>t2iu_taVN>dW+D2W!iA487S$`CZm^$?O9km%ehW znv`$`&KqAuo^|_Ef7!b=Kea7uGtan(#OcrTP>7zCkS?>UZ8lz{Z7Al2k}&h*Gyz-* zS*J?9SJ`gij%3}t;|14^p%f=evZJ{P{>Qc*zlCn9;#;xyO#x!dMo!GJQ93_rfY(;4 z7hQ+^Umgrd!+7V&n{)(wN0s#}4%bx<{HSnH=*D}&AHesF`kKhLt3S+J9y)oCYa$|^ zT^WPzsQ#rtY`x6#u(1l#5{gC2Dq`yG`1n8Mx9`uKc47sq)QS` zcK-ZNv;nTsXw_k8fYYxHm;&`}V4cA^O+#h@J^fq&KM=3jpwIStgqhG0m|`*4*VEC9 z0cYII0%Q<$mnUjo1Z!lf`W*HXEQFj>Uw+wO2gUI|bt+BvC)u{qvD!fB69sa5BHi@5 z!d4#*8!9f$vzH%)nep>h85^gU4^T1Qb3F z2CYAcLY?c@AXJu4P*_s9)DEE!SXeDjRvbxchBK`g;eS>_{K2@vEVX{z<(sIHmekbE zrPrNXL10fwvgO}%ii_9I#+QxR(AG|UJ)*qi2fjdTG8FT|z_P$4#-?g$MfAf=&}vQP zfrUlEAnq7MC?GI$w*K=ZoP8!;!}URO6)aO2ea)WO)ipxAhF{g?$Llhk%SM>^SZy!!uLRZW5!Q_-3z;t0G+y!I0}sr=lR^T65jO zfepgbS-r(dYKp%V=Pyxp13}9k-R!}SgoccZYx+Z)9Z;{0kAMp1EpYrS1zXfhH0nhr z?&?#B!oatF%jrh;uQ2SZ17KFX7(PT;OeezGY*o^MY^Re?mXl&ef+qGS(K?g2I!nS#^@Ni^g!6%LC%cify@ z&&FKU`0wO&zgW$?QJQq|_iG8HZb^Y2Xa z0c{5OZGpuG@<0`Z19^?cAX~g<9Tb}q0askF#fqjm)aK^_t9wYNAjpb0&{lQh_~T|L z7D&^rOe;7#?qb7^<`RljITzNA2o0@&xUgdgsxt2#*l+K4EvNU_NBV3-HgPgomKpb& z)og*5jc6o48Qv2#%ZspWqouRGC=Lg2aHmGQ5b0U&f*-M81cU5t9gBgZ8fyi^F3h)B z_2DDNL%E;@;%75F=T)*j3BJ$=-*ee{(6{TG&q9wQzaV^;$ zyOg9G(-%Y-3o2x+K1+)_^ka{5cb%W6JKSS=FQfgEA~|O1{v=ZGr1qR`HAs711%8?B z(dn1}tspoCeT7Y`RfF%4r)p~(8!gu2Lz~9ewL+W#;M|t_PL=uH-A5&xC-CRoosg6uLruU1JL= zR@EHP&_MDK2 zp5itFV2tbpAbSWd73jXMfve?aPUR-(cnyUS@bbao)`Q-j>5%$#u^!5i3E2jHH%`Wt z^%X42FlQ4=66vz`ggMl~u3JuWWI=XZinTzU? zfSa3pd~c@{3aFv}U9ungdy)S_o0lGs> zP!av~hZT9b7;FN%9N3(UhfgP}F+5nWF=T;fPe3)D_{XLIf z1MztTk~_8{UGeS6t&wcM=MXH zbBx@#(Ihpxac||Z4nDUdFqx!!Bq)zK1%)YhAz(s1Zl$ayq1Ifs8%20b*I@M^C41DN zMZLPL=^4}_grrQ)n!_uAL9Qii7SW(oT5lgNSfg<*J zAExQ(zjwELa!EnCshx>S7UepW!)Dj!C$Vjc&#bo-jt4^H!Y$hgW<1T0x6`w zL3$*LL~-fYkb?<%tVs8s9|mwrV;j0V zpKAiK(Xl!eE1NHQS;h|PobsCW9q;P!@vvMA*%XdM^h5$w2?sY;^{bQY*@pb#r3ZA$ z1Vy&_oUp?Wn|-F9R6RkBbY$@cMDABk9EMDj?=d%Z;$9{WnzFktj@)@ROQWaOfPAc&I;lP^Divrarrq!`H^eK%cMW2_xJ^z?#6wHDl#-><*2T zfH;=NG70Z0tRVXROg+3B7=LS#{&r!thS}M`!V7Wi^B1o}en1!7Xu#rhD7W3qIt2#H z745s{8kTAHHJY^p8>vkJkNsL7^C%U{`MIQaRLumcidofSm8AOcOrbidWWrcA!8|Mv z0mVp6yv3s|ZysfVmOK4mx85O``+0iX-=u7}S~Z}lHeISxhzAd0XR0P+iM$)bdB#a9krM(dC91?-6DlU$(osXvd4Jqbh=Dm z?tAK%k8+*++qWcRb-{-g)ad7y(K4#+Ql|MN5zRZ}Qh&P{5tq1ya8^-Rk19Q^YppQGWt+DU`G5eV;Wy{;F5w*H_ zrPt-G@B?w9R{hgHU0s<(Y#`2N9uLKk9-BOi70A!<8=#D!0U}`%6*8H%SOn*dZdR?H zC)?yHDT3H7>~r#?vgym#5n=1L7}EzC0w}c2B}Z>6dULem?J>)#B_`#5c+{aw&uD1! zywtah1UJfnzp?FvUEwz{kSriMavA<(?8IF`-n_ZBVW^+o6>NI6PuAu~qOp`;u*IUrMtI5+G^cM+`o zudRS!HgdjqQCbog&Gvfh*yGwnS96hsvKA1bYRZ5(7&9^K{IVZ|y#h&S? zPTx(Cx5w#j^bVUkJfC-sj(aPz9MXJ$7-M)jesF1dn+UpRZCd(t5PE7wgQ?=c-n|8W zU_wl-a@w5v(Pr5gECpC1#LMEV!edxeOA2bw#0|5QjJtl5I&kCTd$An6jJB|K=%~hE zYU`g&OFUs4B*6jLX2Y959^w@o4-8K%z15h?7;IGU{(SA=CCQC#?{MIvX;(i z3q$I$K~$|?!T2f@E%;|CASL|rV-t3Pcr^vb20i>ou0_GPmvV*2=yIqs{rz^xX} zSKNq9;n@XNrnD_JPJC@;RN$09nf98Kih>k@?38U2g0T(B7F2SbN(l28=dtQc$w1m< zSO}U4`l5}r&v+SHON=NrYu{_f63+i|y`F|0B~fm0q+*VBaMu}E6P@}5 zsp5o|K*J%?KKZ~-je;J&&`YaQBq~FL1CihG1aUD&Gl0>;iWZWlwQG(Cg5|#=Z8B6C zVBfaWktDA8%#x#DrD6_U_X47m;kAz-EyN&=K0rLq6xYsE55!wil19 za`PUay8nYRfu~R@QR^#DOXnXC-u)j)36K#E*1ET8(F^^mr9HI+Lb&mD(qYhBQ{kS+ zt>FlTnSf2dS|+~AU6Gph-ZK?RReCmIb)nh8JkCDzBM`d>v<|5SKQ3Zlppw16ifA^_ z$%?JphNnuQ7*K^ zq&?WdD|0mH2&+VH5|-~lSmeV_QhoUP1F1<|f$*`7_+|y|np(`E2yiC}yY_nAW1x!( z)g6(|HcR#E&+%2!oigl+c&vyZG(}k@Spli@s!Ngvg6)By zaIv0s{r}x

n!++7~njc=mwrmdU-~h^{G+Yt=y8!{m1Dis@WBhD@b-3t>Q5DZsaP z1hOmsF>Wv)NfH^&L9*d|*;6h$o?zdYj*b_W{))<-VTcgKxo9^o{!Lvs+Hi!h)ky>0 zkFGrxG$xi!`jMZKLH2%1CHL7cdWc8;UW4?YNSFJU<7<;SjET;Ofoa9F3K--Xf44Xn zA5x`s;No$nb;3GKJNQ6C4qyMxlPQp2VYcM27-gWUXD3Vt&q&}mQX?5HS*(gJSMSC_ z3Fk<|VBeL9J~rZ|pN=&yo$OD-eb)pi}%LeI<4VYONMCs2EAYD#c6b8i21aC*TH z7#d*l$g1Q06~fB^RnG+1pu4r{(dJf}=;y*MaYA@nL140mym5bp-w5hgY*t6|F*tdX zW|zBo(_hMfrVC@|XGhvP_=*{k41Ngs?#|9exDks zaV^`f*W+t)87h*nYw<^Ks~+5U&mrR$N|o@L5?%4HO!DNwRulx4S49ZEP-uAh+bK5w zMZ`n8x`K}wHE){;f8mtw6td&7?!+aCcV#HxdAHmciof>F;4+_wDYe(6h{qNG zyoHvyBYV+Q|IUs?o()Fg0T#pc3hXx{lG2Ov6q@ zrse8lJ~Q=qmQjB0aN-2Dh8QF3(lA*eoJs-8l8NoSQ#5_Lq#T2yonzcx->bc)Lpj$| z>F*z`#a}TK&^$9=IC&?D@tx#ep^z(h5vs=&EUg6xjMA(7;%f2eW{aPl>un4s=Vpn@ zIzR`R+siEf{1EQkBXYvQM)$LV zz~y_rvA-J0SGtRIm#ytCF;oQZK(5Y%$t!v_(_55xf+J6;0}0Dh+KwRLa5|O&2Ee;* z?3BoqdYx1(12jBvz$8ow5S(0f6ovvdR%u9CG+C3@ z)8?l4P?ZP)OqwRCUll$lL^OXQbI_cUcVe3%BqYjN29(>I!=4!@oj<>+7xGLvzL}j5 l?&40{vZ1uvW1tVceK4O)<7>%M5CtP}hLY`3nfEy-rme?@jv@d6 From fc6d498f01f595b7d427e8c6bfdabb63e635f6cf Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 19:04:01 -0400 Subject: [PATCH 59/92] Publish - @quiet/desktop@7.1.0-alpha.1 - @quiet/mobile@7.1.0-alpha.1 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..422923daa8 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.1](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.0...@quiet/desktop@7.1.0-alpha.1) (2026-04-17) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 3e0f9e43ac..75656419f4 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 759dcfd465..a50069fabd 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..cf1a84cf2c 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.1](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.0...@quiet/mobile@7.1.0-alpha.1) (2026-04-17) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 58b7fa8afb..053f57ebea 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 581 - versionName "7.1.0-alpha.0" + versionCode 582 + versionName "7.1.0-alpha.1" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index c26979e75b..34f4f8caa7 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 534 + 535 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index d1759d9d05..d935472a8d 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 14 + 15 FirebaseAppDelegateProxyEnabled QuietKeychainAccessGroup diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 4a7478f2a5..90619bd95b 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 528 + 529 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 476241b0c0..94dcb06db2 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 1135dba39b..470b17f5c0 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.0", + "version": "7.1.0-alpha.1", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 58e3cd7d4eeff05c1b1d2755cbcf56ff8af87445 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 19:04:16 -0400 Subject: [PATCH 60/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 422923daa8..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.1](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.0...@quiet/desktop@7.1.0-alpha.1) (2026-04-17) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index cf1a84cf2c..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.1](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.0...@quiet/mobile@7.1.0-alpha.1) (2026-04-17) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From ca9045ff836d6dc07a2bbeca7c64a259c4cd02a9 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 19:09:47 -0400 Subject: [PATCH 61/92] update provisioning profile... with a key that I didn't lose before updating github secrets --- ...ppStore_comquietmobile.mobileprovision.gpg | Bin 7970 -> 7970 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg b/.github/secrets/match_AppStore_comquietmobile.mobileprovision.gpg index 498db5118ddd95998e03a399c00bc1a90a92fe69..22a00aba3fb821c4864551bb6666c70116ff7bad 100644 GIT binary patch literal 7970 zcmV+-AKl=L4Fm}T2<^ECMjmTNIQ-J;0XYGwQ}3V5e^rZ}(9?K*2lCK!rJw^w_@f?a zQ7TuB$PT(HbjL}%HUU*2b;*pFZSy*+sNJ?2!d(f3Rk%k3Wc`6ecdB8B+b2LN{yO7o z)?eg*dA{7&rOm0UNob^@u>0~PsSBw8fPM$Tzh3HSa|$?J5OW<4pjoV6Nx|p|l&)Ao zj6q8ja5^H2%TRChnjOPjv0dzE&qL=U{8f@3WdwO(?MuGS9Z7(p7m--VII11btkR*3 zDbW)J;^xk}6dLc2DNIS{fS?3=g6IYKtUee;;z>wCF8!!<)Y{^og2Dd;<dOGTzx&q^i(hCTD^$7`szJq!Kcb<#Z2Z0P7E6}W;lX0P6 zTNSUiq(4yfi~ZQ|WSpA7VaRsC%wb$9a^cIn_WrD$J9+JhE>#m_i88~B)O)XetoK(H z@LWg!PjC^SD*jbCZ(u{@P&QEQ8GQTCV-N2AV+*{wL~(Akpc-3hP-WbSs62hQ-G~>N zf{P-r_CMwXRfa@6&;gxOks{ji`yAc~3C!{MC zdG&1Q*LlK(#B=NfcpD0?o>Rtr{yRDx?*H`R(W}w5WPB(C$M!ifAz#1QMmC)q6T9{Q5y2>p735M^%=u!!eg8ZV~tifY9s-S|h z7Ok{vn30KX>lxcSlKhYUzxqM|gIkYB{aC}hEqJr9-qxMe(N1R|`qZ|(i7pt&oD=HC zhpT$gi&)Ox6r@!+J+i6y{ttMbsaVygE9}faS9t8FMGi=3Wog33o#In`dC|pg({)@vjcr5Rh*{Tv zO(Qnj;p|8&>fzu0*Z`Txj3WGQtLhZm$&QV#+#_lWu+P&$fY_m)ROab$xNdW5)fySE zHPvb$Gl8Z(zyk6d6-H5_&~7Z3-B?C3I2a4cn!D{%P@2 z$Y2sqx`akQV~jcK^vdh~K6tue6=31M9Ren$|s=9&zz>US!(i(Ijz1e`j~>jxR01kMGP_R!L8d_fO?hLwyc*qxJ#s zV?#_-8>wqA$*q9uwb!_{t}+ru4S9H1hfsqOyTqv|3V)Pyw_1O!J0x55R0*!$i;`Gi zt$N8rrigURq&DjqVQg&aM+j))AV%65q=yITmFl~T{hs~m;I-kikMj&AKYIqQPpIkdjaSK74(!Ztr(v+GZY|e!R_i}t- z-}m!LNVR;_^xcWTdL_D zLedflv7kfc80`xfW_V2-&u;V;n=0r40F-#=rRtm{zG32alYWm8?LI8(7JGQl-Ah4F zIRvo{4o#$okd0xvff2?{B@z)(rF=h@90AD?7m(fH3i1-F?XEQW;oiqd%G~$nxq&yX z={N?esQ+bNKp};vPB55WoJ@ZAf(GLxco_AD=%!TPq)p=3aX%2t*PXe9q8G*r_o@jq z+`Wt1DVB1P)QhiM9`foo(sVO*8v-u{duY}&E8~(00eZTy+@3Asa27!^faBC%QjyK% zNkNY$&CtDq4ENBwkh{#$AUmD8c-paX)M8nC6{v%IB)l3AoO38atr46NI(*M|N`i85 zYJd2DAf1U3QgoRh2%1LsnaGiVXP_rA=K5MfFy&D?-e6!VbQp|sq930NdMm) z=vs|OLh9L914gg8KaCWVuC$9u8XR9uLOsXMN&rZo zjIMXDEw-Afigy;nPTz!SR^Y+n7kzH}+M_`vJ z0-CChK{+qyz)YY|PS5q)zDJr5)~Z{F@EsAvfdl^#CpjRuVL|@C;W4G1dezd0LQ+i! zo`FMO)0G8?>x}PwK(+<6V%ms|O%Qf{g`joPKlOm@uG>El;7%j}g_U>8$@r%B0+#{` zUQXiCd@#o;JWH-1j!UplfrW-@%Qy}{TNhftdqbV_nNmPa5o1J>v`F3j`wDv4n-5VY zf0Kg=8m~ ziKD1F_5Bu&%=&eEp6$`7Buk~1-w4TwxjQo*n{ zSHI)tEI6x5>4~Nj8cDd{`#S-qZ;v=j3LMOAWKw4yxBf(oAXh3aab~%YI!Ndum_^^6 z#}JfSYi2|azEjqn-LT0_ee<%d6=UUa1wT;Eqg&fvB^wXkQ5U;GuoFLc0}|mq&uvSS zF>Lx-zoi8*Ri!>Zk%^5b zoE~KQmz(%OAo@WStELuD$w|lZ#rVnH<7iR4D zxVB6k9oj(WjG2<=4S>=@U5CYxApya@$4vg7IUj1tIoK$) zhF4|V;+⪙?1@=Q^24eBoF`%p)=l9BF!GNJ=hxa&Ux+l?d$AS^{P7vv)wcm?XQBY zt}di4Vy-%YhejOBN?UZ z1qmRu&#rkF5_q5%88z%>5_tt`- zOrA#BM0XhuGnyX{;CTO(I}xXL0`)nzop2?xMzH{`81{1GKg)D+y)p+aOMjh}QX6?i z&&O~fH8DV3#b)cD(BiLp(RN5d&syDfCMCeR1f2xGb0|>fUpyMBum}aO-N4j*D*Av6 z^L6%D1s6)~nVgAOJl=%uuE_!Hq|T_f5S*WwYyiKJ5fwo3YJf$#S(jGjf zID#qF?J$8$_hJXLH7w13yL8Kk-hoUUo;%-j0Cc1{OHyz{s*qtEez@^h_RAGe_K&Ke z-yv?SpB@w_Fc$HY1thB^&j@sp$S6lTrvInL2|yZlocizE9n~^#TZDs)q9KL?zg(Bp zC+L2;bQAvY4yqa8n>19dJ1Hg(N{r+-Tzw4Ecoh$`W{}6X`a-uyL|}wc;5_GBKF|^> zIf7$+?`kydK+aTv@?ugI94MF4c~+|h%n47W31`W+KBnS&D1n$uz?t*R*68Mp@Wg&@ zOxVNU!TB6ds$yMoyXimAFmLjTOS^3d`8luGol1$vm2E(iLbSdKbC(%gU$vFShcfQq zdc{y0PNEp(e(eoX;LeD=0f6ug>uudKIXWv)$5Xm%;GXH;q#TC;|2)HG z&a`{G^kcu}!aYkF`RJu&%YOi~2fLK8Hqa==oFb${1aFl42-hRBUO2>A#@f2)S-=PH zJ|o60r(rU{nPHPSYW_`p$pB*N|5l0Y3c~elX+MmD4g8(wK$Qe<_tf1?xk} z>jE9+vd$D6lJzm;Aik8Kg$4-}D8i@b27ALxJ!V|+PmBSUmWI_VUs~?ebZhUD;Zft* zs|*zoRwKx|dgrojp*G^Frnc@(;K>U@`$BCbp*9twDEr)`O|{QOacIDyRxkjg_l|ft z{6lCt)Tco)va4wrvuvzj%ku33^Af*0ThPi2v-I355vu+s`}bF%#$TQk#mXdxeRPsk zEu8}WZ*((7EDda0WgLK6G?|ds-5f(O(t#r~wB=z>&TThp>b@8N-_enU-d!1^Ny6@W zqcF41wnkGr{OfP1qNO=x=S3QBo`d3geBph0Pa3FXz6C6|-=Uil0g+=1IEW1C{GstA{q_gVgdaRMIMhTthV|`reiM7wf z#5jZjN{EQLNfrBn(PFwMW=X2eq>SqYH391smPR|!7x668%rg(WYFuBfV4SZ)A&nQj zsTrS5a!?{E!DokZaBpi>v$9`Kn{9WlFelSB>w``lOx?T8`vf#^oDgiMxHZUlTj>*w zplojM5EDPH&_9c8s0mVTf>55bm~jV5U~EvH7K$BqZVt;8^3B}Yg+QDL|G=;g-i5aVa?i8o?2GpQ*7(qVxshGAmOw+3UHb75bCP!`K`A_ot^r*hy3{OIG zAb1LE6qn}qBgm-Ki1X!P>5iF_bzFt#uhyC{QTL5RXA5@ndV}q^jM(gmz$qC@Clb*I za5`XOhmgn_vc<*`sVEz3g!E^*J zE$8-pxYXZo@-Jhk znw`A3vHzSzRhVYVEJe;)pa>8t{8~PI?pgKyto_{C0I23Twxhj=J4TR%wdbq4TB1CP zsk+FHk092(XMOQBmP?p-8jTD`k4}<1VJcH!37NVK7nNv7cv^0^z^$iayZH9f$3;_^ZSun)n;>M)+w0;1Q#N0WO z*Bh`>Fzz_*74)N)x z8<8|L)K7mu>PSzU|OCXfcnNzH&4L56&>~VnTNw#I96FD`jD2M4JuG!jv4OaKmhnH!Hotn_XOQ&>0 z4&hYWF6b!QpE#<#!)lqJp{-_`Ky{i+U{QrSxlj^K>tWwUx=%1xIIHXB1p;b6)-JWo zUx9U_Ds-|$>E>-B4~6;^iuy5Vny+N^X+Dq1U+*ad(GC!!hoXKWIU$6lMgwM5YO zA;{}PXbx3=9y>#5OMV>5Q=gTXz%)o((TQk`PW%_TcdHH!DNR%jAC0TIz#}T?!1Bt* z3l{5MZ1RnxhHsNme~d7jJB!RyyMT7O2h?;-R8*YjVYEC*NM1M{ol&?IxJb>{?%zYl%8@#PR>of63|j zE32d?$;vS#wc=KSdL7K-q)95C4&{JfuL98=hY2bJtcNWwDIH>XshJ}VM>AGOJIvg7 zPjnMpcwgVtDR7$Av7uGKRb`(E3%nU5e<+H+e8N0tU2Cu+w~&ATc z;O(7cAe}>`#xk(d_rOT{DKli=!4hF%E9>KMtv(^ho2r->w(6Dtxm-cWP8s=j_6~kZ zaBlSzuBl2cru$>oK_JQ);AG3t3xt-p^#bT7ZcbFDHG-Gjd@FY3N(9U+?>nHy^i1`y2! z>LL+NKoIP(5YVkd7YDPHC)}J;VTO+HJgVNXJ$kW_&#Id70(Tbqf95Ub{yRh}`g3MA z9)G1`D>NK{d(L0`0GM`yBgz;p5kFP3Bi7>?7kKrPw$OP~J3nI(j%R2C2^Ll6u-mzM zx>)|kU#dkbivb2)%e^@jiAM!wbs;OpdX+J3D4=#gD0&*>=VRm^1!iE%P~&b;nb<89 zkuvp(HRv~640;8dIl+!Mif!Tlfr!T7`E^Ghnus8I$l6KP(owK4u2mSG%TQoorj8z9 znEX5Vz1CtHlVi*0^v7x6z(kzq#l|)eMoTwsgLq+y1crI54$nGGlm+3tQZ2HQO>VOK z8GB3=6gSC6goY?2MaVPtVnb`dxa_6V{3tMCMf9+i=F|Ynn)2*=q)!9A;*MET-|}0L zvEuR^u3+qIaYqN<9rhC#_@mzG(M&yLiSRgH<(pdc!!XE<&8hBA0V&ZJM@`el!h6P_ z<~+|)0b%fIpxArOUdH|kYFY1db5+$zvakL~`G@cU3%Vl`bcN*Mt~ZE&S!~t$laobc z2$I$dEch6%yvY3`k;N9ixYH$ng(x%WuhvJmOrdej+}4-q`re4Sn0G42%sm&yN8{j_ zuu3|Eb58R5ePY{R5ed8q9S_@tO!>ip>Ol5-sk{U%vpS%+NS>lNnTgq0{M897f_h0*a8fC{?72!5Pv?%?UCUWD z;i&eV-zdEd@d`&sy*#st-Gz7+0XDkB6}&-#>SV zQ$=ja@1^1{2lwkNSm?HUoOZSLb3|n2nvj_leex(lCXx#a+;<%(B;9NSM*DMcr?A_~ zXTbFyMsX6K{;pOZ$Mc_B+D6zOa+m3%1r)Pp-!y5h#k1M0Jd9tm9O-xfHI)NShi%`X@ddo3 zUgM}Rcq;*S2rfoSz)RdK;jLVsJ%CS1-{U!5bo;%@{7T*Trx=w6Hq6R6IC{P*0O1K! z?z}z;Pd*pUSy-K(WIlx2{WObWCfA&S@q@~hqT!I}UMF~$8F(z1FJss5=iYS|z{j`{ z23MU%0E#3tV7(H3@vBCxCV}G*05p!0#ww9q26hleZUb)G40Q4Fwa`D|*2_{(ffy;> z%6Aml^YdfwFZNy5<~xg&*gyZqQC099wh!M_SmHeT>Jb*1nVHiA$XPwx@I1R=s5Q`d zS7neq@8J`T71k_GV+Kho`U+8Z6FN_|V(>lME&^DCOiC}v YSf;LJn!`?vn;iCR1-oyas=A))(Sep*_W%F@ literal 7970 zcmV+-AKl=L4Fm}T2nZIu(t~NeNc__30XHbIA(l0@Mqx9cXTON1ad$DqX|SaFYR-*6 zYOW_PHcZYsy7t>Dwon7Z;BYYU;Gq@+>LSwb7<(KZhB65}>om(@o48M#dirr4YQ8b= z!~a~yRHI9<`u}IQT0VMh5D|b$TZlYt?G?M5Jo0)H1pS!$!+_zg_92H%1fbnhk0M-P z*eyFT&j^~%CFRTu!R>@P&>jmsMi9Z z^mKwcUuLYz-{#f`ia(%W9M)ylkhm2~oMpb#wQxINegsUjd02byzVd&#R|C9(2e_t$ zl-%o}cXrF;)8=~d*pY4)ZvmA%ToMr(jFPu9YiBWfsFJVtq5KyBDiOfc68R9Ip%ONG_B#zlq<` z#k%rxzEE8$w(;1@PL7VoT7r{c zpPw9gYR${JU1Je*8{1jntq1jklvzpm_Md#=kKX$9BJ2vekvQqKr*)PxZ)QhjaKU1+ z$;ZPYDloS8xMOAtyq+>CSsNpMb|2O7A$0hnZ)Pc8-2FADOaZqBg@zMQE!B-i>C_nS ze)7DC=d3eI&ve!QUgkrS|NI7{S0zVe1hThYg4PwlxIrPc1mCEx^p=_r)lwxI@rt|8 z%AlX>KtjV+(GfSc*(k|vzQvO`-m(9Lb578YwnX_=ohyvlgNrS77n|9 zr%ki|&v7K>>I$TSy)Wo35qy17N{T>1DeWA%>)412+G3;BHU%P_IKY_<^Mg{?4{`;I$9$}8=x9cE|U{P^&`;z z-hyrKoiQY zj9dieC1+-Ib2d=XWz)OpPMMX4d_zU+ya;$W7+sBiA%qgR0Yd3VC$=eUD86)LN=E-3 zzV}el#7tLP7}q|Oh`7H!1&{LRHO`=rzYs)^4?-q?H316!_A^P${XioDy2Ss7fI|VB z`2v1OvYco7-NwK>!KmNjmVIUEU)M00*x@Ey%?OF2w^c7FE&9!mle7<&(7!#$xEHC^ z0t=_Z9$h3+=BXf+@1&}JA%AyYq80k=;A07Sy{Y)6--v4279F1uMCZ=1G7!6urYs~1 z?(qm#I}OHxY~`Pk6O>)PbCMeiN-@RKl3(3a5O^YE7~Xss3!MghZ24~2z#{(a^x6i7 z`;HB%2=Q|d_35#==`fz&97Y?PApuNheNhc010ECvp%io}PAbnvHom%F?_Qk{KS^*# z3=?MpKsh^s;U0$adQ^DGraTj<9;wC4SMF7k*L$tFAIXj&>aBD_>DPu*H~T~HHB z**r|uYxtv==-9R19Z*A!e7njj549A0m^=7on=B#c-eZglAOqYj!j(D7PVs2&`~}PCWYx6; zgj9#evJS+<UzV_tBJblnasPIq~?lM9YcJ}zrVAm)WC9P)8 zZsfwKF`MShRaFN*{zI}|rdQ$c6@Z#W1=pIUmbbXsKR>UR66tOVeoqYiV3T%hG77#3#e{~;* zF8uzzs{XUyn=5k6-&UmJYR`Pf&C&IkZ;a$AUG~4VKzBqo@ToQwSH6R~Z7;K!&pM!~ zxa~gTQfxTwvunD&seCi^Mz^aolGeyLxhp6pLf%fig|eBl;+-SNx`k}?@Tt1w+~$%Q z;ed{zr0`C&|K}Ff0R44caF1R_9ZT`SPCp4aIylKfYnWX^YHH)=v$YJ=4`&m2Yn}6> zO<_QA$qs--=|JN0~UeGQ&0n>WusXV>dyDfd!b_vdv`c7$L zoHpmezT=mUB{spF^Q_RJ-takda3x$ujQpNYw=dbY;3=k;ymygeHK|69eRjCEM}Kk- zZbrk+e?MrUL;VKU;vUIc8lG60jC*p?-1>k6gcDQm#vAkwVsRLkKmK&luDsen%s2OY z_Cl_yJXfX8lk|CH26kDzVk zTfhfbo*Ru3JEu$M>moQTUXyl0x2^WNvjV&CS)Jj3pkO4frn_Fyhkmc84EWJ_F~NF9H+g)c5IHpemJZvu8mXkaFCMJ5 ztw~XJKW5)Y3HPZiMZ{-_K}KXd%|#%nv5#{oxkJ8uW*%z{ho(nmTgwFa8nS`yjBWT1 zFebux<#V`Ykgj)>zcwdhE^m!X@GXwrY@mbCels2y9Os`z#juBnc7{(_7bvtHM_Jy9 zCM8XWF-AHpFV?l0(rxocQrPp|fo4#jKB0Xhi@wCHNpZ3MjP zdyA}Yoe6$;4~BuTfuQ{F^g6Q9M-f8#s_ym?&?|`{afXXT0&mdT-G9#P z6VlXm9~Ndx!RbSwzae(V4maT$+@nC|qwXs=`vwpO1T-XvG^}QKss;rE7>EL7 z8yxCD#P`Mic!@T8;Gvi#`AKG2>BcGH(>EjDgc^cHr-Xjr3}tT^n7FCE&%#V@>zT@i_;FN=%f>VdDNo@*|QTZzLRl8ya zhgznE?yt3A!ONLPf`f$6^?71_bZIiaE&Y7cUzS7{6(iTeLHVjbCBWuZn)H0yS7e!r$>$~kND>ATWz(zP&R+rn|ojGQsM4fN77A)0erxze++mG-db9pR)I@HTy zt2eY^^sZ2ve-ChREQ3)ySxMeNU_~`7sY^eSLnUHzqEnZ3Iqyg@AIT^og|^g1ROq+zv_)t#q1eZG zSH3qX5UnPCZJ#`>r7c@JFaaa_+%R!}Ue{O_)J$iG(PVpN+cWE&$#En)CIOT3_=uCS z-7A8Wf@sF9*Sgnw#>$deZoVDutEGlk_nPh)DzTDCsBm+5dX6$ra*BxaAtv}<`9@h2 zLP+P@3rRv|;b(pWOI6TpCd=e14rIJ%{pf{ObVj&pQhBX+=B#9@V%{>gbrENN{e4qX zLsk$K-Gbghqj2){E@`V@76l{lrN76Ei|%!$fPd{nxvcj_26wC2YETADfZ}u8g6wL0 z9L>ed(%lLYp&TmwL(y<_Z^Wwzrb{5E!IQx;`>2B+WREG#qby}`ZG7`apt;H9SaVF= zxkWTQFPwGdrZ*5{<~Ca-)z&PWv($NCLYi1H@}ESgF=PZpy$Tj6i8`7QG>9UgAQ>Ez?%R$h*y(#~a(CQ9B)?G$ayN)FkS$iw%f!6JPaF&3Y&SdXhI(bEiZI&BFubUWO4y>$- zw4_<+In!BcS{p-=or7>z-?^7qvtaB*Lt#^Wc<~SCMTWe<$A=JaOUi@7mPCsBG4`fW zP=UrQDR;w^?Q)u%LqG%iqP8nSJjnW%G_u*$>KTE_YWoWK8eI@bPK_1G!lv9vWfILR zA>4smkf0e?Ng6Jrcfeh(m-)#V2_k>o6KI@G?o3a`2e^T=I!uND>I6ScPa4rw!ur+& z?|e|1T6YDM&#;IMl&0$W*Sm6*eHdSa+@ZGYxh>Zc!*ZVjCEhCJN;{6XnYG(=1Y@>l~yjcq!2@vnHOD60@3iFl|JeV#$s(i*6|rhzZHqV>a(=hK{| zK8Q!NY6Eek__PK~X-c5BJ7-C`bf7QJRwZmj!4^7mPM z-a@Pb`>rI`hG{Lt%Y%%)oAeKho`-o{Qk8rj2z%;rDBq8p&cuv6D|i`c&ew(>G$gyi z<=5fay&~N;B8{Ut!~(g(rhoXei@+>lZIoPJFIF4$YPhl&jJw95tr|+h#(FyVkiFyc z{&04*W?ZgllI8q+91+!dxot|>w`GsUIIAwRFc`(%A{rt-tn8>Y9z^ynF-~y? z%V@OwT(k%StCKE;!;CWMy2pnu&&HIb2u!9_wgj*B!3CmAfRQ8mc1DHEjVI%ibSt^f z72M+VaAJ7wT1hK$@xx6%7#n&M3B9LQU!fgm@#4f!#aDFHs}49P1Zd5N#T^sp1qygm z$Y>CH>u$=Z+G7+|9!-Xg~8}`$#^F`LmV3`zArCw>xgm#&}IQX%41Zm<)dvl`8@bf zIq_P+@@yS321vrH13s%>8^9Lo%%>*cDy4 z59^?LM_dv4_Wth+9>)(<*M952k_YJBm1?(mv0qZpNj2|%6dc>n9h>?YRtGiy^HA*^ zZW0Zi{GkvH2xtD$Scr&tg!whZu8D!r;LfCA>LCLJ0Wzn+K+LM9HK z;=We!?7Aw`E-2+b5viB83j<6Z+X|9aCrsVd_BOd$^@f}ZcNHqqx@L$PF2c3 zHqs+&8af9ABVqh}%i`AbfYgnhJQHsR{x}OofMjDt(wY$K{%K85BDjy*S4+@nGP=-P z-IT<>)7}j^1s>zSxJGbqOt|>EAf^F`eKO_)UuXcJpZNUMJq(R5cm5`y*`;s4m z`qo0vgO4<5s;GYZ%PCn^-q9>=SGDBZevB9NPRh&e3Dgg8@*m4Zu~=4UYGb=ZAMQ1XhjA=_q5}5=rGhsI zolq~3tj&mr5apBK4KS0|M7e;trfg+6h{PkdG=-V2!857SiwRqhHZ7fUx8XIq@&`EZ zE#bE=QFTl#qLg1c&leTjA{XM)H#nf|AiFn&(-YQP`#;6v&Ij7&hiRxLTJUiglZll zI|0*r(5eW6{J`{OggK5S&F@D){3T5C{Cj}hNzTJbr>$cW;fXJA|B4Cj*Apn}4Rvb&B&Gl#*@LGUvq%>PClH)zh2 ztQ+cYSmK--A1c3gen}oeJlw;N+Hf9M4^P&V* z{n@f$GS)mJ$&s?V4+}o-c7-j+m>?z)F_k*-0h#(L<7$3>2n)$Nl&P&l00qNJ{s=w zw#3L$Fst5Z@~7(g!(5fY7`WWawq4n<{GF5boy_AqaHDUoOEuB1K@I{oxX(83e8kAQiIc=hmUd~Swnu;%Pj+ZI};&MquF_;>F+%$cj=%8Spb_BRDaAF4Oi4P5uo z?sEI)f&tW`l$gy2A=uUrgWg}+Epeh3{R}&|0t;G=gFl)v5BCG9``WuJvqtp}G>0s`9|N!m=fFhyE9Hl{i0`luwW+v4*LV{($r51zDJAXurhA7?lKJmR?dY>%-`M7M z#}S|S3`WQvAGQB0Tu2tAsoc~<$6mh0O_snfj5*0)xo~E)lSAlPsv(4cT*k)aR}oQG z55627T8`}fxlo=haQ^RXsO2mp7G=LbpGUt1%}8UsRDi-zPP;z=CD3!3WJ+Xe@C35P zeEwx3(4dMON8nan zLMCdleT)5`d_E?NEW&4NpUyYJi z@FBnyI_w&lU7MUvsvTbyl96i)zTfS`kAaWLbo2qlwxWLN!JS@r1r2_yc&^Oc{NaK8 zsn{YJGkvTVvTKy=Qa{SK&WtPR*z1Pv6N;*8MQ0;;C0&M4ZVO|;Mf-vRX1xqE3?+l> z_G+`N`g)UgbgDfW@)Tt#2z;iq(p-Don0U=0pd25_!GJ1jN-QNJ?)3m4(x?o~I~9C! zuX$XFU#n4a@S;1%E?`=+dVPVS+~d)uqDewydo<*>$Lv9I4krROYnOUxEy{cj6US&j z4}HkstSQrb(MSD3UXPC~3xdF>;~FE`%qLjw8rJ9Fu{yLTDbUID$C5FN+lnc8Q91t&OU`eQC!s*o!*34-4+CPY$ zJ)h(3qwN{KLmRE@0oX=!vPs%{Pt6Y=N@20`I+02FFg!np)Y`?RH{Ls16Qkz|VSxo+ z`)KfI$5K=O!+;kL+_8mnFI%i(vQW+?^cV+j3wcNYC~6?;EpzuN;bXkA*NCo{kcdrx zq7;U|A%bhR4UoBj;Br{lP`9BAT{PI81Gy05*KHAR7}bne?9|i96|_vPY!KjWSYn=D zza+HiU+S3_f0p#%RUbkv5Gd@6Kk z$OO>9_mq#f%v+s*z)OvmBQTDK(r3t9Cx|Rpc#3s@pYmRE;v`hu%P_0h@r?{}s%X-~ zulQRby;J)knUdhDzZ3${1o}PuBQtEX>?SxJnv^+82pG>H*L);d_v;~R!%&ToUBxYD zdyVsETj(BYb>uYD2kppE)7Fr6&fq%UT~G0}H{4+k?&fCz3l6on}axn_(pabq1~U Y9glqJiPbKj!f_xgM_q(tr#u Date: Fri, 17 Apr 2026 19:10:40 -0400 Subject: [PATCH 62/92] Publish - @quiet/desktop@7.1.0-alpha.2 - @quiet/mobile@7.1.0-alpha.2 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..9d756d43e1 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.2](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.1...@quiet/desktop@7.1.0-alpha.2) (2026-04-17) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 75656419f4..24a4943e0d 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a50069fabd..745b475f85 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..2b5498556b 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.2](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.1...@quiet/mobile@7.1.0-alpha.2) (2026-04-17) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 053f57ebea..1e3ef0de29 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 582 - versionName "7.1.0-alpha.1" + versionCode 583 + versionName "7.1.0-alpha.2" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 34f4f8caa7..3ce6a978b2 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 535 + 536 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index d935472a8d..04d84b907c 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 15 + 16 FirebaseAppDelegateProxyEnabled QuietKeychainAccessGroup diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 90619bd95b..e6c9e6b647 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 529 + 530 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 94dcb06db2..2a5d1c9677 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 470b17f5c0..d89abc6c41 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.1", + "version": "7.1.0-alpha.2", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 51963b94c757892dd5eb4d63661d951566c45b59 Mon Sep 17 00:00:00 2001 From: taea Date: Fri, 17 Apr 2026 19:10:55 -0400 Subject: [PATCH 63/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 9d756d43e1..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.2](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.1...@quiet/desktop@7.1.0-alpha.2) (2026-04-17) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 2b5498556b..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.2](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.1...@quiet/mobile@7.1.0-alpha.2) (2026-04-17) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From d685b81b5f94ddfd95c655e9cc4badb749fbe40a Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 13:26:21 -0400 Subject: [PATCH 64/92] update nse provisioning profile --- ...cationServiceExtension.mobileprovision.gpg | Bin 7891 -> 7906 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.github/secrets/match_AppStore_comquietmobile_QuietNotificationServiceExtension.mobileprovision.gpg b/.github/secrets/match_AppStore_comquietmobile_QuietNotificationServiceExtension.mobileprovision.gpg index fcb051f29d016070fc23d3ecd21a651a84f98546..77edf47b83a8ab97ca89b2cff46d94308dca0a1e 100644 GIT binary patch literal 7906 zcmV<89v$I~4Fm}T2>T&ibJ61!8~oDh0ial^t02!8ERjR-qmQ3OF?)F!nGP_mAoZ%R^L{ z!5DC95Zx#oNkL_F|M{y~?)f>Q^Zx;mU3tX$6Vf-pw>EQ>OdUuH(lq7^Z)xkCb8Ekd zNK`g|@V^gO$1j{pks?T!;{!3+`K487Jbp4hpay%9@X)P}w@G(y(yI50`%#o(WRr;aI10e_qwAM!_ zFc6@5_gHR9uSJHRtti~p@td7qSJ_2q*_}(`vbUdmwN0K!6A$iCv%Lr_R}&8yGc{v4U_vEYazm`wLxh;HwfHC4UnCVs~Q|PhCpy4H+2V*EQ0?-UNW^SX`H- zZ->DEtf>1BGc!|yyP=uvdr5dwFG(4$ z5Gi04Tk5^rNTxNb`Fo_3!yoNoq6p&ru+hGm%qHUElJv7zVnCdJgVrmQR3Lb!xG6ai z$jVyoX!~wP!ShJkBxsxQ&z&oxkBD+k4765S+`0ZDoHM;SBZy!T4)1*Z2ohyr)doXE z^;VV4kH@Pu6+BkgofdnH^u78HR9c0RG7m@1L`YP&&6FJ{v8xuKBgFmTEv$^qr7V@+ z2e(#g4pqwF(4w_M^(W&gGg3zzin$8@ddEueTLg%?2Jf|{1pP#7w7(&1X2TD4sSz7h z-%w_jx*xJ^Bexht>b~0ym{4{O-z`kJSy_DOpyQeBM^+k_(r%Wa4i%yG-YOH8jwr}8 z0k^&tfQDreXY)qmfRjs`9h{D~vMJDjD>{Mv4LF=h^aJ!0MgXic2q`Ypc|t&?g&`Eo z!vC_EB8T@Uj@R4$ZiAu`N`0awTuEs8OTOF=ru*{rnX6~>M2pc(yNH~I+EWBkexJnK&=4F;NAH=UIBh4y_14P z;`f;u>S2DmFLy^C^cTDaMe{Hu$*(o_J-Y5kvD1}ebq#y_=jZ)N`rgj4%GH7>kr_So zOnxE6;RUB5wc2*Q!2p6>5hRtTUM5v`)oDOC`wqfklI##^H=?ZUjV5QSKUdC>tCJG} z7day{7#Iw)Cn;fOwTYZhlZLPN|9|Q2O2Fc2PEC4HcLbTu@ugAWDobD-6cTj_Ri78`urz33?e@ zdNqnzvA3qqVzxIs*4h(9)ReUG$_GQ^U!QGPmarIRYzn(J-1tSlw+S8bTqHSj@wx4 zOqi{c@t21kb(M9!!iEVpcwiEv$K&tnFXEYJV`EZC-Q5>B+wN>Mjkxu@Cg-qKOw?Jw zm7qV69)EGN8#KUX-ADSpEH9i8;X$m?iRQ17nw1X2@#c_%0f9w8HPMDuq(cs?KmHZ= zuO6A@b2EbbHjhiN_%F<*d}S<@L-_v*71<~FIN^+yEMxev^}$kti82ix^`~! z9n-~cu0OL$IZ+LV4#M?RJM6uL$MJq* zu5HM9;<6A5PC{-i6}Nq(1xWZ3MpC62pC5DlxD6v=HM?6G+Mg04gwVd~@7QE0l`Dau zOVZ}Ab$MB%A2PZ?mH@c7B}-?(-Ghmi0jfkx*D?&Y*<)!+Zq>Ttr+uNX4n(HlWLXEH%cNmttfMF` z^hH)$5S2|wQ7Kt-bvVdkQ0h>Zmj&8?!0muQ1RGZk#^4b-P?+3t-ukg_^N4O|Ere0o zj5%ei^55Fg3c<(b)3~t`zqh@r$O=nHL?p~JYgjF#_Y4(lTI(=AMd>=~o5dz7^`2e^#|D;`A|;}Pkot%`B&*BoK`UdK2SIh@bmmMnh&~ zzyMywDK{@*A0xv7W9G~0v!S*7R<-ajqKl67uKrJsNT;q3YTqHV;Ka-DOGV9>e`xNt zQaadQBPMm(4b4|4xXpie`_YBwbh#<{1YA0EU=IG*ug6W^aiRD0XeY_%Tt0j<}&1uM$PPwQ>`$yq20DLf3w5K-K`Wm~Rk=(HBMc=m2+}VzM zDM1xU8yEqXHt&`^L)CLY0kpL2=6!}D7431)j`s_C6$D?E41IK|8{Esam7;vbO7C~o zW1jWZ_ulz170Iw(>@Z)`w~dWX*8AFXI$aUUdRI+k3_^}-eo@vPUp$B!UETlEJrVwz zpRT%FP59NKGQ3*!LbinHFk(*W+R@(X%%Ld&$CUzLLI0(_M>SCJV`P1~J1{lgr34IV zJ0f3l@`pTtH&g32=raW4bY_REW1Ab%){ro&p~Q=xt z%Ou~jMRgqbP~-0_P4-dXakOO$FgF2WSv%ThJ|YhSU@{fE_HAh@H<=J_=iUn-QScw_ z%%8UXnWHfR6Iz)W@aT93UBo?N{9xLJ#1h^YBbNpb&h>--t z1WUBw6Djo{@=n`u@AUhHfa32i9HtY5=j#pElM(e!E3+Tk0pu{b;?k#jgj@0dJ1%;e~ybRpGE@GcniTNZw_ z2QzCpV)?TbE~}tJBd%_>LBr`~8_lrdoibtiU5Adzk|Ex?>T;Fc3KxuGKbz+o&iKj0 zL9j8%bK#cgLAVDo8R+6Ypy^JQa{*KJPh>)gElM}5HR1Y$s3TnQf8*=gGkW=1Gn!ot zo^V?$6WNXO)D|b@O!4k2x|IEXNR-hmu4K0pW+3M~ZnuuvTnRNnCNs;jRzC`m!YjOXq|-w?`;`HeHx^q5?oA8dn7#yeTT};p8~_p9e4YU! zHloU%TM=@oWp>CP*3R~Aq@`MmnDY@YWqhhQS{6uRa{CR`4X%4Koq3@C+f{v8G5i_4 zhKYZ|xYR4n5GirepA2MWs3psLee&G4v16k%Fy)ttXJ?oRQZobu0r=E!{K9L2=23`n z{q_Rs zYv5#YLk7nIfa55OhiZDvVv-ZJwTIZ3A6bl6uve>ZqEHhI?IJpf?>z$;w^K{gyrc@h z8ey<_)7YrZ=5k@$U+9xksems}SawFeWdrtgiHtZC1zU9e+Y5C*yu{aVOSKc8Vh^av z+{Y@mlRSr`K$$`;hI5p^9mj5Xg$tQVHGqQPQfAg})}7o-z(JL3DAZ@;(|#6knPNwF)4sGAX9 z491C^7wcr{rTDpWRQ#yz>8|Z?9@tTB7Sv>0^8P*X-F5sCbWpjFd#;!6;*GntN@iAZ zvq)w$5(;WJ35zhsV<+`SMSUlOwzp{S1Mr!!E-hx2qBXyDpmh6mwfa?9PEC zk~p{_u^%Ae*ylowX<-zihGXGHfXUt#jMnB!z9QG%s?OQ?UU? zwaQSewxR-cUTsRUG%V$*W_4qpOuDYaM~Prtaz(X2MbIGRfJY0;jFMIX+;_YkO1G~> z*FSdnjRw4(-v}*bP-Ob=ILvR1sWr9|jB z&jU&uX>|)yk3X5l(<_i(l|em|M4!g(CK>g%I$NaD--sVd%8+dlFsKnjp?&_^X?tN3 zj3{=bNNo2OxXb8|g;B;QxCwf3*p>l^EvLS^us)1}o8TXp8Dy)>n1x@?eBW~g3!a7m z(&_v0S$rc*GqEe9=t8b;zzj{F@=*B%wc(Tm=ItQ*3LLK|9&QGIprSIHbO z@4R`>VC;C3!0>c29*_q_a@i%yYQ{J;+z;Ew;=C3cJXCZT}D2K%qc#LZs_g;UN5z0b{qkhJN-*}|hicr2p9 zc(|D$oA&-`L8IAThXOrMBMx?CJ~Ww0)j86v&yREPC`@20g$SH!@ZycF;?|fA=%ja_ zFsckbu)se<$a#X$o@wz;>(TN*S*bB-0-iN%(k*zNUw!aD zaL4#jR>*M6x?qefDcUv#Irs_-XmqZ?-NZ4`U^;`b& zVR^7;N%SERZfM0|r;^=4`CnKpB}oY4&&vZ7j0a2f&RLb%7a+ua?!#>8^~1kldZ<2o z2F(6)Krk!78UaX_K}zfMpfHxxj(0dMCq3;gcFq^6ssRwq@Yb3{!??ms6o$z`ho3vprYLMaRFajX$SSM?YQ-aWt^KEOg`jq)zWaD*6Z_7+{97*@ zHL9%|m|9)Id#9cW4k~`Gr*n@5@WpxsW-Y*jDi9Axwb|?o@Z~3263{z07xAF?L6P9@ zNzBd!BTrTq0jGunNECvQ2{RODkYQV`z*MCGih9fGE>R#&M~i(bB^YaurnOFD90y87 zZIKU>`O|vF&pNka*vJ*sZWq;teGrJ3irEVnOuq9hpa}Qo z_Loy{LiW)(7>4GlpLK|2N~Ixl$f{>7nPO5C3gH76*oN1%nJZiR)Q_ejfhQ*5FDlw0 zTarjyl(t-qzP$7E%vel{@*^Gd_`t8V_@3YpH+a{AG-qd3Y+V9K^tm(xdAh&-kSy)kDUW&Am{8uI*OP=_7`pcFs+nIh#x@5zIZ z8GU=UT1#gMNNxE|bIs^3yj;Q$MT>u`j5*4vINrnFChlgdD&uGNA*yvmFi5~RY9^QN z?mX5qtYOww_WZOrP=+GWTeybv-6udM1CKkQ@E{RU==7#sl`Tnk#a>NK8Ric|(+n6U zptzSg)dE(Hob}m(f(OFLi*@4U4rTxXa13+ig(?De!k=v$^<=_Rq|!KkT_7-Krz2hs z2&_+k*qhQv6~uJF?U*`@Bt-56dbP^+82-+eZ>*(KCr)O2|?WrYf3d zYbZBWda3n`q6#NAzDvvc=Cl`82P*hNeAGh`=52I?r9=^uI12RnX&pZVw+D`<)(j6D zX=J61`H&l|-XCXGDM~h_t_n=km?ORMyF3*0Kl4mk4iaX2D(UDM)+{_q4Uy)W!4q`nc%_>E7Yi z1j&$UmgK6$*=e%S&yZl2>x%IjOf>NG-P^SMQt@1O=Ygd?^6WO0^LWa%Xq^@+GqoM^ zV_490!wp&VJ6(kHPHee8Jo+;pe*^!@i1wPMv>fni(K0!0ytcm}6#w06@bE6y#i%fS zkmpDBRZ~VvdaYuMoxNi@OGjEDg13+SWO7){{|&LRufXOpJwkhb0C;4Q-2y#O zZ(DgS#`Di7dhO?mSl<+5VSgT_OcbE!E=i1TclOaU&{G-+dzFw2PDwlQzjvf+4Xpw= z_MxgaYKS<)_@e7w5jT3@OC;F5V^E|E%>Xi7>X7bd5FqM6#J+7L&kyK*vvbX|T};3Z z%=4f?fHDDp%?bP5;fMB|>ao@KZSDTIf92Wy!Cl()G|Db7CUET7A;;xS!tN=i8f*|P z@k^|t4Yqt{w`wL*3Vl}nc|_3J%3v($5$U0mqd9+{x{x8!l>TGL(LK|omgq@H_qN8^ zT8QFx^(+Is;KC^J+ZKln$XFX`Lu`C}^ZU{;Ka}gDWf_GCOFz)}p0`&VF}!M-jAph!y<+>a^t5 zuXIrF0MOBq?^km=1#&W*^`~|5O&@m?i3Jn}EdWsrp`n#%oIH62kcdCiD9szlYYtd` zvtKGZ!&@! zpNrj|zXlf%?S89xV$>r)ZH1Ga^O$AB4mffotHNh@)q>U%i~erJX?FpZwcg|lUc7cP z#C8-$d}c!eo(Kk<`*Z8SMOmriajw8qu_KkxSu#69Ai3KwA@UamethM9R#Ssmo|m5% z!j2r)Qm&~e4_I7FfD}#ft+8=A$WxsIpDDPiOQ2r6AEzCGL`ghhWXlJ5B^Tut{)y$) zVcJh-&f5(kfxRzO55u;|c?cWYo=)-MjK$RoT{qK2#X?2N8Huo1etD9_+}sh3m#=Pz zy2ja^C)p3JNC-2Gb=Xn7`c;n(?tx3GU>Zf+lUo3RzO#!EgY=`2Y(?AM6_jjgpfJl9 zs#BHq6k`a=&!7P!9d0m&4U1(Zk#m`zntihkd_~ZJjmFdxHju*}HZD#=0!R_ci0Pt~ zkRr$V#`+DqG50~j3%3034S8i7z-j*Fw>^^(_1?B(D&$7+GbiwBI7F?2n`dY^jWFK( zuQ}becKf?j6Up!8?4w8M0hgitU5j25TjZ4o8dV*-aO;Umn^UiTa zui5bSIGgo^m+O1?>PzPG=d)q846#gTg#v%p29JXb^6E1KAy@phffSS(NVOa2Gq$(g z!U>2@;es0Nec`NQ{$6mS>24ScJ`j2l%VewY$zK95j;btX}zZ(_~*29sX+Y2jYEAB9Cw#^e{VqG}B4xmyJx$pxl*_6fXgwF#HTF6Qf+#(eSnvxIICzE4f3~ z0(WBJA$X#?Q3w(WIF9D%J;51lBTzQm+vyqAHO|p@gQ63Nv9Hzuf0cts6>d~mf9piB<^ES|D zpv7=ORs^8d3)GCow2#v>%vG_Yg4k71io5z!F9s~ZfV%p%9NrpfwfkufCGRc#Yh61* zmMxa4hC7V^%Cg4hn1{b-gh20y6woNLS2e)Qybr~?hnQS^r3rc7^`WM~JeBXo^OYEB zwL;AXoCfEpA6LywWx+cruVUjwHwo+n1-w>0s&TeR^K7?%nwUV0g+aO{U%IUIF2(niA3@q-g3^=|e=)aUk_iU@?Kgx+= z^hNTr(`kF!XU8>pK8TYu-T?N6L>oU4tUay&jd5e)24k!Knvv#Va_O)e{uTOAiGPv$4xSZyARjQFZpY4=k4gW)#lk(hxmh4 z<|(ZYFQ7Z70*{?aSvy| z>f~yHj%)hC8yQP~H03_~0n~S*tpAodPO-bbp84Rciy-Lt2N}S_l}Fv`W>RW@lK}j+PnPqcv&g*~QmTaj(8j48Ldyl%mtO)FK7ME+(R;1@w*`L3sF5Xfl;aueQVH*- zZR|4SjX2EBsM}Z6CPAS71c(3hFl7t>p?DbSqwI84MHF#E2 z7moG#G%f=FyGO-&RVNwk7`4PduS{-ZR z%Lp6H!T-frjQyB}WyWcbBSsb<&gKu3NuD|dz}vl2pGIC;^3U=!fFtPLGtT+n&ISJt zq{*uAjT*QJs(dI3Hkx#4u=fAv8(9^2(VrwAhqhrf+s_Y0A;8%8P^t;AlYw`r2!0ov zDc^ivK(#Uv3b}4iS1T!s!h3Y7DbR82>7(|--r@^tDIZkkTb0ygJB>`>RBac-b!E=1 z{Eyn7y#9J~cvmkv%rac^Q-WqWTb5$?itVqiPavec?H*uIsr6R6r5Oa>vKkXiFfeg@ zUKyqKSCpQgpDr$J%J?yj&fLy*T})GhBgxxR%`?qEai)w`4Eg1(vBB}e?;=4Qg&zhY zd@$RwZc}aLd~r#j(0WrAmZJJ+Jh9|}xQ|#>21-lTIq?f~^1neq&fBK;7{tA36%R#4>oh2rFaZ7nBhJ`3V|o71`wDovt&DLO=Ua*SM@5sdh)%bT z$#2_qCRbRr>QQQ07@dQyK$TBBWpmTs+y*a7d6ex6)|9Lj1)h#>dSxeB6zPE!!KE3) z8!AD})2f1TXosnpOszZYi!MMj9e{gBfh<3y6B7HyH4L&UMH(1GGW`QpF?Hi zGqL!2;BIOUW`0wjCnlqlcSMm0F27gw=v-+ELXAnsp)O-1PD77}gg4#zl2`{Fw6{9> zljXt<66`GZw#}b`^e5*f1<6%KXJd(SPV^Sh`WUEUm@pAUUDRQzHzxUtuYCLM91kIt z>GFK0Mn@$M^Gr;J&YOR~HMab@NceNXirl*zJMIsDsWq%93q zM15Y;Mz#Zc&}i^PpfKmi5}lG0X{h>@&%maP6mGaw2FmX185KpMFxVd8ZtuvvYS;8E zKfcLzR&PM^)IU7Z7}qqe0Duo|Lv3vBs?@=kJIMu*j<(ys{Zeb)&hJvfJ*Ac2dJm*p zib77cvJBurS}xz=Rn@7>A-1yo3EIEDVbe2`&8#~DPEcM0>UPD$($TmK`a#Z4h?JkR zEyLi@Y8tR7rCzFj4mH{ibu@sWGrpM{7wDN0tFIm=U7rFGM|?RXRAH<(o3`a{rJs3%mmD$)fwJ}<6Yr!@}3xME7elf+B4 z1AJvD!|1Hhn8ibzn+6t?jY~%$g1Bn}fI652Kc6|I7dK~_<~%FCH{s2OY%6{1R=5Kh z@YLc8p0kB|JS`{U`Is%1c#)wLiy^ArAnkLIDaBS8Ka5^}f#A1b_7Cct? zI&Dn)3Q#k84!@)wXzlGKizvPx#Uf&+P`Q2wIhDVu9v@N|L26vrM>{)r1(WqV(aS2P z%l)fmf}WQdgL!UGy_Wg@ZgHfx=Sn!a24;#S+Q2$9#09<@`0!fty>~0vDavvGWWMwA z?9IXWUMP^&Tl5hq#Ke1WZ^o776;c_O0l2dQng_$!0hY|iXa4tY-KAr{H4g@Bp39kg z7$x0)jQF{KAZopKm{u9W27zacvgd@~n&d>{v>D^wWzMcIr>Tgk*bmMqpVipH;;+Y) z_fQdv=mYeHJFiHtKrY@m4l`x-%+41{z!+yESjJuJ5rw?Agw5HB%ECWPc}qUq2+cf0 zSt7^RD>r2Bw9{ikQ|d#|mY{k7DLpV%Q5pLfi7!_;{CeL-w|e?Mwi<>;X6$21u5R|d zp3io!jRakXYFgCnrF0F$=gN6vWL075(#-83*VIu799C$QpVzxAR{?Y(p2pHApG8cs%jT=f25~{J9m46Re09{wB5NN((3snx+n6vYhLv#c- zTskGbfy^(xAE&`r-Hcua)fx&YPpM#;`l4vC{h%1i+$hi+eNi_U2}$~J1r5+s{bO*LIN_dg z%6W&S%uc-~7!qzvrGC*Dr^Z76WhD@Vv(bHOZOLfJgq^?g8w)U!tsg1TLJtI5{i;kz zWDZ5%gwxrhS&BmS5gBJgX})Sbi!LXA#7OkF`e{yz1})o*vurP5n5_-sbE{sIQfTR^wDH;qdYkk-dAPD7|0r{N?30fg`4D9X=8=~E*V zlRu7LT~a@Csg^HmsRVB|&9@FNrPbg55BM6HvV^S4DfEY(GA&EB86-NAyZu+nM=Bp}FzQIgBov*o@!C z+RBZq%yRGRs*KyZf&uu}sAn(^d`z*&PaneY2YIFt2E1u^Z|6 zo|r%YeCr5y@_LI0i2zIjWkNOu523v8`;8=pfQZo!7Z#qtDgr9NSSEiuhCLJJ&}(R$ z?j&S}HE*2IWITh?KFFS#CojTATIyP#?8wpgCf$I+x;kLffQE@CzX5^-EH78cV2iJ zwDPpZN?yKW4KVjE3XjLLEP%dsbMXt+up91ic+f?l4FO&V)7K;gW#~zjXaFc1t|2ejBf`7NE5L)@)26M@Ran zp;30zx9L7N5l4)kncPsY8tta}iWLD}VoNVKcO9gm4J6KVJkFDZwp}>t^0D0AN?ipw#MvNL=dzkV#Q0|q7d3? zGL;-pBYF-!V{CJN;f8U1k-etBdn8LIK(Vwi!Qg^0?7Se*SQ$PfV2n4W_ z^5uivhM-C6etn(XKm>o4sT#L$Z}eRGJEDeMPyI_%mq?pb;?neVdcC>S1zREY%`Tgk zSIZ$pb(-crccnr1pnt` zw!srDUVW;nRjg_pXIXR1S#&*$o?e6&?y`uj$}lXNe4G}fbrGJViarRj2L-aH?Pl{) zba+z~Xy+Wy%oq}k=0qI~uYmYn;X9oN_0W@z-i>0pAq5nU(D@r`9=JgFD87T8BZPts z8qFYSD9ggM&{J-@r63Usz;gu00&_@DYLIUDXaEV{NDd`vZy8rX4HY~b+9kMbI+$is z!JA(b{^Gz{@DAb!kA#gZj!Q5uFr$*4FbY)|l5-UKV}(>tN+ar*S4WqpF6*&YvW5^l z^02H9nq@{tNHF9~b2v5jB!23x07HrBxuOStXI?2yrg)#>J6V;58Ic`a&Tcjz%H`Bh6BFfE+#X1Dpm+~dfh#CT&V_l#p)VDn@~=voP&RQa zOX8uPSR8B=QOfdge@B!W65}N0@P25;hI&#ua45Cq66Xbh4An4fim*;<*wMsH5ua>^ z8X9b79^mFHHC3t4&9t&i6wvz3g?obrBf;M~yhSoTWmLDL^MD)&0=&OIFkkNnt;0S^nz_DzQhGw_Y&cIHa zEC7+EjEUevo=!Ng3!#*t#;aGeEu?tiDZQ3@5(Pt=NSagJCwhFVzHrkuBJ{{sviT65g-+5F;48VRl25 zg(V+>T^G_-3qM^R<-y0H?CPqb!mu26GwQx?&S`D$^n^l%r9-SU!WYqbOi|I#nc0@Y z9=`fR0sPl4Alu;8hIL>743!XahY9g`Ik?!Iv;v=O)~@?0d$lZL!pe=}hh6QLFAVtt z2t5Y&T&{Fr)r&v9wJKCb(C1@C1K)r{j_lPC>VZq5%_BrXigC7T&q(P3aZ*U<)-`=_ zjeXJNKjLW5X>OlD!QUTB@OOg(vvkEBj-HL%H0U@yMSY(|0R??WTWtT|j5j#fVL{Xt zPw~bXC!%pE%bs@trT^sqGXBf&#>z`Dgx-4BACY5E284MP#%< z$Lcr}u#-d-b4f86{xsA7erNYN-RJu#euQ-M@A{}8vvc6?xVE4j7jBIi-3%6gKf1U@ zbung_PC(O_lxP zv$6e+9y2*KAz{TR!uljOree%aE0(6quu0k}m1eHXiM6ieb$S^LNeT`WH4H3Bqj8=a zCza;LYhE|^{v8R$0)x{&+;1<-cd25G)xq-uzwo>`rS?j)65u|=2P8Lt-F>Z6+@{uz zOoy6DK>=f~aBfvdTW`BtN8q{#{NM5)(k{dqMg@Tlw%hY-$S9Q<)AKJLOhj z1QCK=4dtDgZB1jKO0{2qHa}H23C^zppR)zHZWkM{T4{R)}YAhN2mPpIuJg zI2ErxGY#`veg4pAyEb~!Mc-#d3@YSvZ#b9OxIYo>dzl3+aJXj^j(F`t)@Obj%ohp=sQ{8zk!WigJyIY4p3i8m>GRq#?=8Ielx*Au);6I@3x#kNL!zq8$2a zLPI)(7IYzy=VIeMql>4UQI37dreu_x)bdhZBXKs9ycBeT!2B>ZRbGaO(!PPbQUUu5 ztJ6>jFX{g`tKU5CsGc@+Q7WI0A^Xc#JTTywe{Tx>Sx6*INN>>DSM)B0GUCieHyJXK zl;NvijZ6zd3ETH>U*@v5b^IKaYcrLuO191jU(qqdo9|Q7&vn{y&IK210XKS4*PX~})>%04(*camu;zaF;5^Yb zj*6~~UF=Cf(<^pU75IP@(6wv(MNwV})ZfS8HYtR&L|@mZZ2Nh)KG2q+;nspdo_RF% z9}V%Mtk5)e;{l}>bG?m_c~=>}xPTdBer>~MEpL^&_Cs=Qq$*m4cF$f|90oV)SIdd~ zntJxkr~wy)=U1TzrIZCC!F{p5w!w}d&`;($%=g`^)IQ6vJMrn5l z7nn9P3VK{2W++_dW6Ocx9#Rm+D#GT6RIW0<<7tG=lwiO)v1%kPuezM zArr*5I|i2KWK{w?x|x&570Nn>ezko&I~^WZw#*CAA)P0Ls!~=kvmw@N>L6NAqjTG& z9`rPoEO2?~eC+%lNUQK@`|@(h0tps2m7@m8MbFMLs)%f}#=IsQ%z|>@WFXe7cv#gH zu0JuzGYS3dwAJTyvS*pLRfSEPR%`AAhB5#J`N{zF49^~}HqwmSGN=^=zJUzKF}Q~= zGq6Qx&!4ig^MX`ekmoa$e~}cT)S`O{M)L#kn>QK-X*cQ=M$iYpve4ln{>6Q8F&=0w zXIc~@h{=h~I1Gbv5t-;0+ESV4`qNJ9E=y!(7op)kYsY=22Tez zJOQhky-;<<>PEG1{uJJw-V0EI*TW4dj|#Fu9vMv5ISex-*?L&XV)igk62-guJ@2zd zy>b~8cD`>KSeP}`wdzB7S}-?oL0w-K1Z{gVIGLrNntkFvOdqe{c>&q@n1B~4qknx; zasb%lx13Ca7DZxT!v`k^d>GIeEHf&8lc?X*Z2~1qYg*tOIFNO&Lufu9pz_8o0iXrr z?&TSqad3m zpPz3u69|{rWU@G*37NPD#>h;@SOcER)p_xgxSPK%cjW8dHa3R|JIdf0z@hlkxRpK_Q5 z54rKK_Jo>ARh;P%8THSVx$?Mhdig}ck7ILTq2&*K6RYd~k&80BF(rQ&nG*tTIxTWv zM%oYFx=Q+??-4l7fQakiSJm!1wYH9C!bR=~ z%NpTYEd=6oB9KcwD&g(J5;-XV!NKr&_w&1x(cg|@h9Czm$PnY&?9?*Y=xGxtt=6p` z<9N5TniOW|-qllYE1w$kn(7!7ley98ZlNcnvmdKeZGMYF*_v~qhM>G3qczyaG03|> zT48z+tQ_<(Y4APY;ctwbx+z>gjFM@DX!pnBmCn;rIKz4WCx&yk%j<5qT-1IpIDPt# z;k?XnaLqH7U6eaELw619#kSA;4EwT6bp?=4Qyi94sy~m0K4yme7N%<_{3Zx~(D>ma zkSxrkF8YZDD45EVz`pY(mt(HK{+@k+)bRD+{s#vh{gpm7O%=I(r)sRK1PQgLMNo> zW%SMk*jUDxu^97A*xOsNxOp~4!k3@WvrAoSsD_l#0v$wK=dpuv`Kem@pmpl!7ARr@aRCMuJ z5(MtB>rdy2iAR*!XyktWd~RHSsmS9HG4^uoTI20k)`%1rwmqaqR?1OVV68?kq%^?J*KBVpU0D`RIIDlHOUh@z7EmK^BR4V|W-Fp@3O5x+44pF4) zPRH|bzV(wju=yCC?&LkUj0`Y4UuAG^ z*HZjQ)_VVwIo6Zey?$Es$rT)3Ui>-K7=1=dJHU6gTBexoCR@`2%d1dEd}qUS*+F59 zeC&B)i5U8IBup07sO+`SfkIYH{%|tFl3-3^OnBK_q!2O=CzEFcDLQ)0X)px^hbwJL z?VfVXgaCy)1&L9Y%NveuYaiuW1&FyL-`J+o84%3;N-kX+Dj0<%7`eOk^!nB_!GXKZ xlqq*oh9((ec9Z+o1nFZR0J6xeH>E)=xu{|`-3FOPWw#I;a43xQ$1k98%F@ByMG*i1 From f819e29bdf79727dae14196c334115420b377d80 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 13:28:43 -0400 Subject: [PATCH 65/92] Publish - @quiet/desktop@7.1.0-alpha.3 - @quiet/mobile@7.1.0-alpha.3 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..ad024d6f5d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.3](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.2...@quiet/desktop@7.1.0-alpha.3) (2026-04-20) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 24a4943e0d..d2147d526a 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 745b475f85..0532206ba9 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..eae9a679da 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.3](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.2...@quiet/mobile@7.1.0-alpha.3) (2026-04-20) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 1e3ef0de29..dbf7d18165 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 583 - versionName "7.1.0-alpha.2" + versionCode 584 + versionName "7.1.0-alpha.3" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 3ce6a978b2..7a32bc9ef1 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 536 + 537 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 04d84b907c..25b237c5ef 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 16 + 17 FirebaseAppDelegateProxyEnabled QuietKeychainAccessGroup diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index e6c9e6b647..582f77dda6 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 530 + 531 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 2a5d1c9677..f33f710f8d 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index d89abc6c41..b81d1b46af 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.2", + "version": "7.1.0-alpha.3", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From f622f885911657a59a9de9b01466943bb25b836f Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 13:30:36 -0400 Subject: [PATCH 66/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index ad024d6f5d..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.3](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.2...@quiet/desktop@7.1.0-alpha.3) (2026-04-20) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index eae9a679da..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.3](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.2...@quiet/mobile@7.1.0-alpha.3) (2026-04-20) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From dd174f46c95dc5e32bcfb29c4fe43e8e6d47153f Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 17:29:40 -0400 Subject: [PATCH 67/92] Add NSAppTransportSecurity configuration to Info.plist --- .../QuietNotificationServiceExtension/Info.plist | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 25b237c5ef..8e42061f62 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -22,6 +22,19 @@ 17 FirebaseAppDelegateProxyEnabled + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSExceptionDomains + + qss-lb-d981e6979ef09da9.elb.us-east-1.amazonaws.com + + NSExceptionAllowsInsecureHTTPLoads + + + + QuietKeychainAccessGroup $(DEVELOPMENT_TEAM).com.quietmobile NSExtension From affd39e05db6275d563bcbc3207c84dc71ffaefa Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 18:02:39 -0400 Subject: [PATCH 68/92] Publish - @quiet/desktop@7.1.0-alpha.4 - @quiet/mobile@7.1.0-alpha.4 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..7316c6561a 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.4](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.3...@quiet/desktop@7.1.0-alpha.4) (2026-04-20) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index d2147d526a..7f6eb16bc2 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 0532206ba9..6b02e1733e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..e4f63f1e43 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.4](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.3...@quiet/mobile@7.1.0-alpha.4) (2026-04-20) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index dbf7d18165..71c561e346 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 584 - versionName "7.1.0-alpha.3" + versionCode 585 + versionName "7.1.0-alpha.4" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 7a32bc9ef1..8e26dd1a63 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 537 + 538 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 8e42061f62..14beeff364 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 17 + 18 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 582f77dda6..5739c160e2 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 531 + 532 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index f33f710f8d..5d1fe87e95 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index b81d1b46af..d1db1ce713 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.3", + "version": "7.1.0-alpha.4", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 6fb63daf1ad3d20936f548c46de7ab544c39de40 Mon Sep 17 00:00:00 2001 From: taea Date: Mon, 20 Apr 2026 18:02:54 -0400 Subject: [PATCH 69/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 7316c6561a..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.4](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.3...@quiet/desktop@7.1.0-alpha.4) (2026-04-20) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index e4f63f1e43..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.4](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.3...@quiet/mobile@7.1.0-alpha.4) (2026-04-20) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 11d0036ef6dfdecc354af68d46abd916f5928bed Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 12:32:14 -0400 Subject: [PATCH 70/92] Allow CI to override environment file for iOS build process --- .github/workflows/mobile-deploy-ios.yml | 2 ++ packages/mobile/.env.staging | 5 +++-- packages/mobile/ios/Quiet.xcodeproj/project.pbxproj | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mobile-deploy-ios.yml b/.github/workflows/mobile-deploy-ios.yml index 3b2671e750..833308fe37 100644 --- a/.github/workflows/mobile-deploy-ios.yml +++ b/.github/workflows/mobile-deploy-ios.yml @@ -86,6 +86,8 @@ jobs: -archivePath build/Quiet.xcarchive \ CODE_SIGN_IDENTITY="Apple Distribution: A Quiet LLC (CTYKSWN9T4)" \ ENABLE_BITCODE=NO + env: + ENVFILE: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Export .ipa run: | diff --git a/packages/mobile/.env.staging b/packages/mobile/.env.staging index b1133b69de..1382197df3 100644 --- a/packages/mobile/.env.staging +++ b/packages/mobile/.env.staging @@ -2,5 +2,6 @@ NODE_ENV=staging SHOULD_RUN_BACKEND_WORKER=true COLORIZE=false -QSS_ALLOWED=false -QSS_ENDPOINT='' +QSS_ALLOWED=true +QSS_ENDPOINT=ws://qss-lb-d981e6979ef09da9.elb.us-east-1.amazonaws.com:80 +QPS_ALLOWED=true diff --git a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj index e7a8bb7bbf..7be1e9a1f6 100644 --- a/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj +++ b/packages/mobile/ios/Quiet.xcodeproj/project.pbxproj @@ -5077,7 +5077,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\nset -euo pipefail\n\n# Choose source env by configuration\nSRC=\"${PROJECT_DIR}/../.env.development\"\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n SRC=\"${PROJECT_DIR}/../.env.production\"\nfi\n\n# Destination inside the built app bundle\nDEST_DIR=\"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nDEST_PLIST=\"${DEST_DIR}/Env.plist\"\n\nmkdir -p \"${DEST_DIR}\"\n\n# Create/clear plist root dict\n/usr/libexec/PlistBuddy -c \"Clear :\" \"${DEST_PLIST}\" 2>/dev/null || true\n/usr/libexec/PlistBuddy -c \"Add : dict\" \"${DEST_PLIST}\" 2>/dev/null || true\n\n# Convert KEY=VALUE lines to plist entries\nwhile IFS= read -r line; do\n # skip blanks and comments\n if [ -z \"${line}\" ] || [[ ${line} =~ ^[[:space:]]*# ]]; then\n continue\n fi\n key=${line%%=*}\n val=${line#*=}\n # strip optional surrounding quotes\n if [[ ${val} == \"*\" ]]; then\n val=${val:1:${#val}-2}\n fi\n /usr/libexec/PlistBuddy -c \"Delete :${key}\" \"${DEST_PLIST}\" 2>/dev/null || true\n /usr/libexec/PlistBuddy -c \"Add :${key} string ${val}\" \"${DEST_PLIST}\"\ndone < \"${SRC}\"\n"; + shellScript = "\nset -euo pipefail\n\n# Allow CI to override the env file while keeping local config-based defaults.\nif [ -n \"${ENVFILE:-}\" ]; then\n SRC=\"${PROJECT_DIR}/../${ENVFILE}\"\nelse\n SRC=\"${PROJECT_DIR}/../.env.development\"\n if [ \"${CONFIGURATION}\" = \"Release\" ]; then\n SRC=\"${PROJECT_DIR}/../.env.production\"\n fi\nfi\n\n# Destination inside the built app bundle\nDEST_DIR=\"${BUILT_PRODUCTS_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nDEST_PLIST=\"${DEST_DIR}/Env.plist\"\n\nmkdir -p \"${DEST_DIR}\"\n\n# Create/clear plist root dict\n/usr/libexec/PlistBuddy -c \"Clear :\" \"${DEST_PLIST}\" 2>/dev/null || true\n/usr/libexec/PlistBuddy -c \"Add : dict\" \"${DEST_PLIST}\" 2>/dev/null || true\n\n# Convert KEY=VALUE lines to plist entries\nwhile IFS= read -r line; do\n # skip blanks and comments\n if [ -z \"${line}\" ] || [[ ${line} =~ ^[[:space:]]*# ]]; then\n continue\n fi\n key=${line%%=*}\n val=${line#*=}\n # strip optional surrounding quotes\n if [[ ${val} == \"*\" ]]; then\n val=${val:1:${#val}-2}\n fi\n /usr/libexec/PlistBuddy -c \"Delete :${key}\" \"${DEST_PLIST}\" 2>/dev/null || true\n /usr/libexec/PlistBuddy -c \"Add :${key} string ${val}\" \"${DEST_PLIST}\"\ndone < \"${SRC}\"\n"; showEnvVarsInLog = 0; }; 1827A9DE297828AB00245FD3 /* [CUSTOM NODEJS MOBILE] Mock .node files */ = { From 4d84fb0eec488ae23dd4c7180d410879f498bbc8 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 12:33:22 -0400 Subject: [PATCH 71/92] Publish - @quiet/desktop@7.1.0-alpha.5 - @quiet/mobile@7.1.0-alpha.5 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..ef2c32ab87 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.5](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.4...@quiet/desktop@7.1.0-alpha.5) (2026-04-21) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 7f6eb16bc2..e5eaeaad0b 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 6b02e1733e..dd269b083b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..85f85b17d8 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.5](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.4...@quiet/mobile@7.1.0-alpha.5) (2026-04-21) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 71c561e346..a7a9ab2072 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 585 - versionName "7.1.0-alpha.4" + versionCode 586 + versionName "7.1.0-alpha.5" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 8e26dd1a63..c20a7338b3 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 538 + 539 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 14beeff364..98c060bbe4 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 18 + 19 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 5739c160e2..2b0e347325 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 532 + 533 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 5d1fe87e95..08cab2cd6d 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index d1db1ce713..459ac70fc1 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.4", + "version": "7.1.0-alpha.5", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From c7d4c14d95a319861adb1624b01765f7e700f347 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 12:33:36 -0400 Subject: [PATCH 72/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index ef2c32ab87..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.5](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.4...@quiet/desktop@7.1.0-alpha.5) (2026-04-21) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 85f85b17d8..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.5](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.4...@quiet/mobile@7.1.0-alpha.5) (2026-04-21) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 2aca57a59ec9f33711b1b5b8083e25361e2408df Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 15:25:26 -0400 Subject: [PATCH 73/92] fix creating community on mobile --- packages/backend/src/nest/qss/qss.service.ts | 40 ++++++++++---------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 8e07a63e07..190c1d2b83 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -448,8 +448,6 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul } } - await this.emitNseQssUrl(this._qssEndpoint) - // wait for our socket to finish connecting let connStatus: QSSOperationResult try { @@ -488,27 +486,30 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul if ((process.platform as string) !== 'ios') { return } + try { + const community = await this.localDbService.getCurrentCommunity() + const teamId = community?.teamId ?? this.sigChainService.getActiveChain(false)?.team?.id + if (teamId == null) { + this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found') + this.logger.warn('Community', community) + return + } - const community = await this.localDbService.getCurrentCommunity() - const teamId = community?.teamId ?? this.sigChainService.team?.id - if (teamId == null) { - this.logger.warn('Skipping NSE QSS URL update because no active community or team ID found') - this.logger.warn('Community', community) - return - } + const qssUrl = this.getNseQssUrl(wsUrl) + if (qssUrl == null) { + this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') + return + } - const qssUrl = this.getNseQssUrl(wsUrl) - if (qssUrl == null) { - this.logger.warn('Skipping NSE QSS URL update because no valid QSS URL could be derived') - return - } + const payload: NseQssUrlUpdatedEvent = { + teamId, + qssUrl, + } - const payload: NseQssUrlUpdatedEvent = { - teamId, - qssUrl, + this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) + } catch (e) { + this.logger.error('Failed to emit NSE QSS URL update', e) } - - this.socketService.serverIoProvider.io.emit(SocketEvents.NSE_QSS_URL_UPDATED, payload) } /** @@ -708,6 +709,7 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul if (result === QSSOperationResult.SUCCESS) { this.logger.info('Successfully signed in to QSS, starting periodic log pulls once connected', teamId) + await this.emitNseQssUrl(this._qssEndpoint) const authConnection = this.qssAuthConnManager.getConnection(teamId) const startLogPullInterval = (): void => { if (sigChain.team != null && !sigChain.roles.amIMemberOfRole(RoleName.MEMBER)) { From b970ff19a6c9de420333f77f1540016cf9576d51 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 18:03:25 -0400 Subject: [PATCH 74/92] Publish - @quiet/desktop@7.1.0-alpha.6 - @quiet/mobile@7.1.0-alpha.6 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..9f1d903846 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.6](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.5...@quiet/desktop@7.1.0-alpha.6) (2026-04-21) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index e5eaeaad0b..e16693425d 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index dd269b083b..a9a1ff4c57 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..0c10e814dc 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.6](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.5...@quiet/mobile@7.1.0-alpha.6) (2026-04-21) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index a7a9ab2072..4ca372c7ab 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 586 - versionName "7.1.0-alpha.5" + versionCode 587 + versionName "7.1.0-alpha.6" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index c20a7338b3..72a363884f 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 539 + 540 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 98c060bbe4..3c0c5e9de3 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 19 + 20 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 2b0e347325..37ee2e652b 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 533 + 534 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 08cab2cd6d..2d1c790e7a 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 459ac70fc1..60d24d182d 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.5", + "version": "7.1.0-alpha.6", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 7f5e9818860e137b560062bae2f12de2b95d66b1 Mon Sep 17 00:00:00 2001 From: taea Date: Tue, 21 Apr 2026 18:03:43 -0400 Subject: [PATCH 75/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 9f1d903846..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.6](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.5...@quiet/desktop@7.1.0-alpha.6) (2026-04-21) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0c10e814dc..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.6](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.5...@quiet/mobile@7.1.0-alpha.6) (2026-04-21) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 2398d965254ea92b8d756e99195c49f2ab008297 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 00:47:50 -0400 Subject: [PATCH 76/92] refactor captcha modal to fix ios crashes; --- packages/mobile/src/App.tsx | 4 +- .../Captcha/CaptchaModal.component.test.tsx | 99 ++++++++ .../Captcha/CaptchaModal.component.tsx | 125 ++++++++++ .../drawers/Captcha.drawer.tsx | 236 ------------------ 4 files changed, 226 insertions(+), 238 deletions(-) create mode 100644 packages/mobile/src/components/Captcha/CaptchaModal.component.test.tsx create mode 100644 packages/mobile/src/components/Captcha/CaptchaModal.component.tsx delete mode 100644 packages/mobile/src/components/ModalBottomDrawer/drawers/Captcha.drawer.tsx diff --git a/packages/mobile/src/App.tsx b/packages/mobile/src/App.tsx index a4c8d3327b..b0ffc134dc 100644 --- a/packages/mobile/src/App.tsx +++ b/packages/mobile/src/App.tsx @@ -55,7 +55,7 @@ import { UnregisteredUsernameContextMenu } from './components/ContextMenu/menus/ import NewUsernameRequestedScreen from './screens/NewUsernameRequested/NewUsernameRequested.screen' import { PossibleImpersonationAttackScreen } from './screens/PossibleImpersonationAttack/PossibleImpersonationAttack.screen' import UsernameTakenScreen from './screens/UsernameTaken/UsernameTaken.screen' -import { CaptchaDrawer } from './components/ModalBottomDrawer/drawers/Captcha.drawer' +import { CaptchaModal } from './components/Captcha/CaptchaModal.component' const logger = createLogger('app') @@ -130,7 +130,7 @@ function App(): JSX.Element { - + diff --git a/packages/mobile/src/components/Captcha/CaptchaModal.component.test.tsx b/packages/mobile/src/components/Captcha/CaptchaModal.component.test.tsx new file mode 100644 index 0000000000..42f82dcc10 --- /dev/null +++ b/packages/mobile/src/components/Captcha/CaptchaModal.component.test.tsx @@ -0,0 +1,99 @@ +import React from 'react' +import { render, act } from '@testing-library/react-native' +import { useDispatch, useSelector } from 'react-redux' +import { captcha } from '@quiet/state-manager' +import CaptchaModal from './CaptchaModal.component' + +let mockCaptchaState = { + captchaRequested: true, + siteKey: 'test-site-key', +} + +const mockDispatch = jest.fn() +const mockShow = jest.fn() +const mockHide = jest.fn() +let latestOnMessage: ((event: unknown) => void) | null = null + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})) + +jest.mock('@quiet/state-manager', () => ({ + captcha: { + selectors: { + captchaRequested: (state: typeof mockCaptchaState) => state.captchaRequested, + siteKey: (state: typeof mockCaptchaState) => state.siteKey, + }, + actions: { + captchaFormResponse: (payload: unknown) => ({ type: 'captcha/captchaFormResponse', payload }), + }, + }, +})) + +jest.mock('@hcaptcha/react-native-hcaptcha', () => { + const React = require('react') + const { View } = require('react-native') + return { + __esModule: true, + default: React.forwardRef(({ onMessage, siteKey }: any, ref: any) => { + latestOnMessage = onMessage + React.useImperativeHandle(ref, () => ({ show: mockShow, hide: mockHide })) + return + }), + } +}) + +describe('CaptchaModal', () => { + beforeEach(() => { + jest.useFakeTimers() + mockCaptchaState = { captchaRequested: true, siteKey: 'test-site-key' } + mockDispatch.mockClear() + mockShow.mockClear() + mockHide.mockClear() + latestOnMessage = null + ;(useDispatch as jest.Mock).mockReturnValue(mockDispatch) + ;(useSelector as jest.Mock).mockImplementation(selector => selector(mockCaptchaState)) + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it('mounts ConfirmHcaptcha and calls show() when captcha is requested', () => { + render() + act(() => { + jest.runAllTimers() + }) + expect(mockShow).toHaveBeenCalled() + }) + + it('dispatches the solved token on success message', () => { + render() + act(() => { + latestOnMessage?.({ + nativeEvent: { data: 'mock-solved-token-abcdefghijklmnopqrstuvwxyz123456' }, + success: true, + markUsed: jest.fn(), + reset: jest.fn(), + }) + }) + expect(mockDispatch).toHaveBeenCalledWith( + captcha.actions.captchaFormResponse({ token: 'mock-solved-token-abcdefghijklmnopqrstuvwxyz123456' }) + ) + }) + + it('dispatches a cancellation on challenge-closed', () => { + render() + act(() => { + latestOnMessage?.({ + nativeEvent: { data: 'challenge-closed' }, + success: false, + reset: jest.fn(), + }) + }) + expect(mockDispatch).toHaveBeenCalledWith( + captcha.actions.captchaFormResponse({ error: 'Captcha cancelled by user' }) + ) + }) +}) diff --git a/packages/mobile/src/components/Captcha/CaptchaModal.component.tsx b/packages/mobile/src/components/Captcha/CaptchaModal.component.tsx new file mode 100644 index 0000000000..ca54dfb0bb --- /dev/null +++ b/packages/mobile/src/components/Captcha/CaptchaModal.component.tsx @@ -0,0 +1,125 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react' +import { BackHandler } from 'react-native' +import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha' +import { useDispatch, useSelector } from 'react-redux' +import { captcha } from '@quiet/state-manager' +import { defaultTheme } from '../../styles/themes/default.theme' +import { createLogger } from '../../utils/logger' + +const logger = createLogger('CaptchaModal') + +interface HCaptchaMessageEvent { + nativeEvent: { data: string } + success: boolean + markUsed?: () => void + reset: () => void +} + +const SHOW_DELAY_MS = 100 + +// Polyfill: react-native-modal@13 (bundled inside @hcaptcha/react-native-hcaptcha) +// calls BackHandler.removeEventListener which was removed in RN 0.72+. Its +// absence crashes the modal on hide. Install a no-op shim if missing. +// (addEventListener still returns a subscription with .remove() so leak is minor.) +const bhMut = BackHandler as unknown as { removeEventListener?: (...args: unknown[]) => boolean } +if (typeof bhMut.removeEventListener !== 'function') { + bhMut.removeEventListener = () => true +} + +export const CaptchaModal: FC = () => { + const dispatch = useDispatch() + const captchaRef = useRef(null) + // Guard against the library's hide()/onMessage emitting repeated cancels + // after we've already dispatched a terminal response for this session. + const respondedRef = useRef(false) + + const captchaRequested = useSelector(captcha.selectors.captchaRequested) + const siteKey = useSelector(captcha.selectors.siteKey) + + // Latch the first non-empty siteKey so the native modal stays mounted + // across community-leave flows that wipe redux state. Unmounting the + // library's RN modal mid-presentation crashes iOS WebView. + const [mountedSiteKey, setMountedSiteKey] = useState('') + useEffect(() => { + if (siteKey && siteKey !== mountedSiteKey) { + setMountedSiteKey(siteKey) + } + }, [siteKey, mountedSiteKey]) + + useEffect(() => { + if (!mountedSiteKey) return + if (captchaRequested && siteKey !== '') { + logger.info(`Showing captcha for ${siteKey}`) + respondedRef.current = false + const id = setTimeout(() => captchaRef.current?.show(), SHOW_DELAY_MS) + return () => clearTimeout(id) + } + // Pass no argument: the library synthesizes an onMessage({data:'cancel'}) + // when hide() is called with a truthy source, which would re-enter our + // handler and loop. + captchaRef.current?.hide() + return undefined + }, [captchaRequested, siteKey, mountedSiteKey]) + + const respond = useCallback( + (payload: Parameters[0]) => { + if (respondedRef.current) return + respondedRef.current = true + dispatch(captcha.actions.captchaFormResponse(payload)) + }, + [dispatch] + ) + + const handleMessage = useCallback( + (event: HCaptchaMessageEvent) => { + const data = event?.nativeEvent?.data + if (!data) return + if (respondedRef.current) return + + if (data === 'open') return + + if (data === 'cancel' || data === 'challenge-closed') { + logger.info('hCaptcha cancelled') + respond({ error: 'Captcha cancelled by user' }) + captchaRef.current?.hide() + return + } + + if (data === 'challenge-expired' || data === 'expired') { + logger.warn('hCaptcha expired, resetting') + event.reset?.() + return + } + + if (event.success) { + event.markUsed?.() + logger.info('hCaptcha solved') + respond({ token: data }) + captchaRef.current?.hide() + return + } + + logger.error('hCaptcha verification failed', data) + respond({ error: `Captcha error: ${data}` }) + captchaRef.current?.hide() + }, + [respond] + ) + + if (mountedSiteKey === '') return null + + return ( + + ) +} + +export default CaptchaModal diff --git a/packages/mobile/src/components/ModalBottomDrawer/drawers/Captcha.drawer.tsx b/packages/mobile/src/components/ModalBottomDrawer/drawers/Captcha.drawer.tsx deleted file mode 100644 index 69a904bf8b..0000000000 --- a/packages/mobile/src/components/ModalBottomDrawer/drawers/Captcha.drawer.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native' -import ConfirmHcaptcha from '@hcaptcha/react-native-hcaptcha' -import { useDispatch, useSelector } from 'react-redux' -import { captcha } from '@quiet/state-manager' -import { defaultTheme } from '../../../styles/themes/default.theme' -import { createLogger } from '../../../utils/logger' -import ModalBottomDrawer from '../ModalBottomDrawer.component' - -const logger = createLogger('CaptchaScreen') - -type CaptchaStatus = 'idle' | 'loading' | 'presenting' | 'error' - -interface HCaptchaMessageEvent { - nativeEvent: { - data: string - } - success: boolean - markUsed?: () => void - reset: () => void -} - -export const CaptchaDrawer: FC = () => { - const dispatch = useDispatch() - - const captchaRef = useRef(null) - const [status, setStatus] = useState('idle') - const [errorMessage, setErrorMessage] = useState(null) - - const captchaRequested = useSelector(captcha.selectors.captchaRequested) - const siteKey = useSelector(captcha.selectors.siteKey) - - const visible = useMemo(() => { - logger.info(`captchaRequested: ${captchaRequested}`) - logger.info(`siteKey: ${siteKey}`) - return captchaRequested && siteKey !== '' - }, [captchaRequested, siteKey]) - - useEffect(() => { - if (!visible) { - return - } - - setStatus('loading') - setErrorMessage(null) - - const presentTimer = setTimeout(() => { - captchaRef.current?.show() - }, 100) - - return () => { - clearTimeout(presentTimer) - captchaRef.current?.hide() - setStatus('idle') - setErrorMessage(null) - } - }, [visible]) - - const closeScreen = useCallback( - (logMessage?: string) => { - if (logMessage) { - logger.info(logMessage) - } - captchaRef.current?.hide() - dispatch(captcha.actions.captchaFormResponse({ error: 'Captcha screen cancelled by user' })) - }, - [dispatch] - ) - - const handleSolved = useCallback( - (token: string) => { - logger.info('hCaptcha solved successfully') - captchaRef.current?.hide() - dispatch(captcha.actions.captchaFormResponse({ token })) - }, - [closeScreen, dispatch] - ) - - const handleRetry = useCallback(() => { - logger.info('Retrying hCaptcha challenge') - setErrorMessage(null) - setStatus('loading') - captchaRef.current?.show() - }, []) - - const handleMessage = useCallback( - (event: HCaptchaMessageEvent) => { - const data = event?.nativeEvent?.data - if (!data) { - return - } - - logger.debug('hCaptcha event received', data) - - if (data === 'open') { - setStatus('presenting') - setErrorMessage(null) - return - } - - if (data === 'challenge-closed' || data === 'cancel') { - logger.info('hCaptcha challenge closed by user') - setErrorMessage('Challenge was cancelled.') - setStatus('error') - captchaRef.current?.hide() - return - } - - if (data === 'challenge-expired') { - logger.warn('hCaptcha challenge expired') - setStatus('error') - setErrorMessage('The challenge expired. Try again.') - event.reset() - setTimeout(() => captchaRef.current?.show(), 150) - return - } - - if (event.success) { - event.markUsed?.() - handleSolved(data) - return - } - - logger.error('hCaptcha verification failed', data) - captchaRef.current?.hide() - setStatus('error') - setErrorMessage('Verification failed. Try again.') - }, - [closeScreen, handleSolved] - ) - - return ( - <> - - - - Prove you are human - Complete the challenge to continue. - {status === 'loading' && ( - - - Loading challenge… - - )} - {errorMessage && ( - - {errorMessage} - - Try again - - - )} - closeScreen('Captcha screen cancelled via button')} - style={styles.cancelButton} - > - Cancel - - - - - {siteKey !== '' && ( - - )} - - ) -} - -export default CaptchaDrawer - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: defaultTheme.palette.background.white, - justifyContent: 'center', - paddingHorizontal: 24, - }, - content: { - alignItems: 'center', - }, - title: { - fontSize: 22, - fontWeight: '600', - color: defaultTheme.palette.typography.main, - marginBottom: 12, - textAlign: 'center', - }, - description: { - fontSize: 16, - color: defaultTheme.palette.typography.subtitle, - textAlign: 'center', - marginBottom: 24, - }, - indicatorRow: { - alignItems: 'center', - justifyContent: 'center', - marginBottom: 24, - }, - secondaryText: { - marginTop: 12, - fontSize: 14, - color: defaultTheme.palette.typography.gray50, - textAlign: 'center', - }, - errorBanner: { - width: '100%', - borderRadius: 8, - paddingVertical: 12, - paddingHorizontal: 16, - backgroundColor: defaultTheme.palette.background.lightPurple, - borderWidth: 1, - borderColor: defaultTheme.palette.typography.error, - marginBottom: 16, - }, - errorText: { - color: defaultTheme.palette.typography.error, - fontSize: 14, - textAlign: 'center', - }, - retryButton: { - alignSelf: 'center', - marginTop: 12, - }, - retryLabel: { - color: defaultTheme.palette.main.brand, - fontWeight: '600', - }, - cancelButton: { - marginTop: 8, - }, - cancelLabel: { - color: defaultTheme.palette.main.brand, - fontSize: 16, - fontWeight: '500', - }, -}) From 6250e76909ce1503749e3030035bc8a30d33fa4d Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 01:01:56 -0400 Subject: [PATCH 77/92] Publish - @quiet/desktop@7.1.0-alpha.7 - @quiet/mobile@7.1.0-alpha.7 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..bbd741e187 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.7](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.6...@quiet/desktop@7.1.0-alpha.7) (2026-04-22) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index e16693425d..0c983ee00f 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index a9a1ff4c57..fe7335d336 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..d62358b18e 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.7](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.6...@quiet/mobile@7.1.0-alpha.7) (2026-04-22) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 4ca372c7ab..95a582d3ab 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 587 - versionName "7.1.0-alpha.6" + versionCode 588 + versionName "7.1.0-alpha.7" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 72a363884f..6067eb7627 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 540 + 541 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 3c0c5e9de3..e5f6f3ed36 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 20 + 21 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 37ee2e652b..7b74e30ef8 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 534 + 535 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 2d1c790e7a..ce1dff29f3 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 60d24d182d..a54deefdba 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.6", + "version": "7.1.0-alpha.7", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 9b0057686f99a4c9cd7a861a39f3503715726ffd Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 01:02:14 -0400 Subject: [PATCH 78/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index bbd741e187..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.7](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.6...@quiet/desktop@7.1.0-alpha.7) (2026-04-22) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index d62358b18e..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.7](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.6...@quiet/mobile@7.1.0-alpha.7) (2026-04-22) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 15afea2292ea20b6ddbbdf42592a94127b8618c5 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 17:08:39 -0400 Subject: [PATCH 79/92] use staging configuration for prerelease --- .github/workflows/desktop-build.yml | 5 ++++- packages/desktop/.env.staging | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/desktop/.env.staging diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 2cd28e4ae2..b8a40a5b34 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -164,6 +164,7 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Install libfuse run: sudo apt install libfuse2 @@ -252,6 +253,7 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Before build uses: ./.github/actions/before-build @@ -301,7 +303,7 @@ jobs: version: ${{ steps.extract_version.outputs.version }} status: ${{ job.status }} slack_oauth_token: ${{ secrets.SLACK_BOT_OAUTH_TOKEN }} - + build-macos-arm64-prod: runs-on: macos-26 if: | @@ -332,6 +334,7 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Before build uses: ./.github/actions/before-build diff --git a/packages/desktop/.env.staging b/packages/desktop/.env.staging new file mode 100644 index 0000000000..e6d88b4329 --- /dev/null +++ b/packages/desktop/.env.staging @@ -0,0 +1,8 @@ +# Node environment +COLORIZE=true +QSS_ALLOWED=true +QSS_ENDPOINT=ws://qss-lb-d981e6979ef09da9.elb.us-east-1.amazonaws.com:80 +QPS_ALLOWED=true +LOG_TO_FILE=true +ENVFILE=.env.development +DEBUG=state-manager*,desktop*,utils*,identity*,backend*,-backend:auth:cryptoService,-backend:Tor*,-backend:TimedQueue From e7f4ee85df2e59c05a904679fa7f7597bd696b82 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 18:08:34 -0400 Subject: [PATCH 80/92] Publish - @quiet/desktop@7.1.0-alpha.8 - @quiet/mobile@7.1.0-alpha.8 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..991ae8943d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.8](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.7...@quiet/desktop@7.1.0-alpha.8) (2026-04-22) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 0c983ee00f..a62d51fa07 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index fe7335d336..2b8a0d91ac 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..346bfa9423 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.8](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.7...@quiet/mobile@7.1.0-alpha.8) (2026-04-22) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 95a582d3ab..2c77c39fc7 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 588 - versionName "7.1.0-alpha.7" + versionCode 589 + versionName "7.1.0-alpha.8" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 6067eb7627..2d7a80b4ab 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 541 + 542 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index e5f6f3ed36..2f2b6fc29d 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 21 + 22 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 7b74e30ef8..e6938dc4e0 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 535 + 536 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index ce1dff29f3..909d02ee21 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index a54deefdba..6db365e242 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.7", + "version": "7.1.0-alpha.8", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 8ccdd6164481aeded56a18e16712b36dd404e819 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 18:08:52 -0400 Subject: [PATCH 81/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 991ae8943d..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.8](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.7...@quiet/desktop@7.1.0-alpha.8) (2026-04-22) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 346bfa9423..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.8](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.7...@quiet/mobile@7.1.0-alpha.8) (2026-04-22) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 4c8221ab411e34d0cc33a0f1eb8553a48bacdd9c Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 18:53:10 -0400 Subject: [PATCH 82/92] update qss test --- .../backend/src/nest/qss/qss.service.spec.ts | 226 ++++++++++-------- 1 file changed, 120 insertions(+), 106 deletions(-) diff --git a/packages/backend/src/nest/qss/qss.service.spec.ts b/packages/backend/src/nest/qss/qss.service.spec.ts index 425db6834c..ae92f2e3e8 100644 --- a/packages/backend/src/nest/qss/qss.service.spec.ts +++ b/packages/backend/src/nest/qss/qss.service.spec.ts @@ -188,6 +188,37 @@ describe('QSSService', () => { return (await localDbService.getCurrentCommunity())! } + const mockSuccessfulSignIn = (): void => { + mockedGetAuthConnection = jest + .spyOn(qssAuthConnManager, 'getConnection') + .mockImplementation((_teamId: string): QSSAuthConnection => { + return { + active: true, + joinStatus: JoinStatus.JOINED, + connStatus: QSSAuthConnStatus.CONNECTED, + on: (...args: any[]) => {}, + removeAllListeners: (...args: any[]) => {}, + } as any + }) + + mockedSendMessage = jest + .spyOn(qssClient, 'sendMessage') + .mockImplementation( + async (event: WebsocketEvents, payload: unknown, withAck = false): Promise => { + logger.debug('Sending event to QSS', event, payload, withAck) + switch (event) { + case WebsocketEvents.SIGN_IN_COMMUNITY: + return { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.SUCCESS, + } as T + default: + return undefined + } + } + ) + } + describe('connect', () => { it('connects to QSS when enabled and an endpoint string is provided', async () => { await initCommunity() @@ -197,85 +228,6 @@ describe('QSSService', () => { expect(qssService.canConnect).toBeTruthy() }) - it('emits the NSE QSS URL from the endpoint passed to connect on iOS', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'ios' }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEnabled: true, - }) - await localDbService.setCurrentCommunityId(community.id) - - mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) - const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') - - await qssService.connect('wss://community.example/ws') - - expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { - teamId: 'team-id', - qssUrl: 'https://community.example/ws', - }) - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } - }) - - it('emits the NSE QSS URL from the stored endpoint when connect is called without one on iOS', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'ios' }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEnabled: true, - }) - await localDbService.setCurrentCommunityId(community.id) - - qssService._qssEndpoint = 'ws://configured.example/ws' - mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) - const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') - - await qssService.connect(undefined) - - expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { - teamId: 'team-id', - qssUrl: 'http://configured.example/ws', - }) - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } - }) - - it('skips NSE QSS URL emission when connect uses a non-ws endpoint on iOS', async () => { - const originalPlatform = process.platform - Object.defineProperty(process, 'platform', { value: 'ios' }) - - try { - await localDbService.setCommunity({ - ...community, - teamId: 'team-id', - qssEnabled: true, - }) - await localDbService.setCurrentCommunityId(community.id) - - mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) - const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') - - await qssService.connect('https://community.example/api') - - expect(emitSpy).not.toHaveBeenCalledWith( - SocketEvents.NSE_QSS_URL_UPDATED, - expect.objectContaining({ teamId: 'team-id' }) - ) - } finally { - Object.defineProperty(process, 'platform', { value: originalPlatform }) - } - }) - it(`doesn't connect to QSS when not enabled and an endpoint string is provided`, async () => { await initCommunity() mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(false) @@ -570,33 +522,7 @@ describe('QSSService', () => { await initCommunity() const initStatusOrig = await qssService.getQssInitStatus() expect(initStatusOrig.qssSetup).toBeFalsy() - mockedGetAuthConnection = jest - .spyOn(qssAuthConnManager, 'getConnection') - .mockImplementation((teamId: string): QSSAuthConnection => { - return { - active: true, - joinStatus: JoinStatus.JOINED, - connStatus: QSSAuthConnStatus.CONNECTED, - on: (...args: any[]) => {}, - } as any - }) - - mockedSendMessage = jest - .spyOn(qssClient, 'sendMessage') - .mockImplementation( - async (event: WebsocketEvents, payload: unknown, withAck = false): Promise => { - logger.debug('Sending event to QSS', event, payload, withAck) - switch (event) { - case WebsocketEvents.SIGN_IN_COMMUNITY: - return { - ts: DateTime.utc().toMillis(), - status: CommunityOperationStatus.SUCCESS, - } as T - default: - return undefined - } - } - ) + mockSuccessfulSignIn() mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) await qssService.connect('ws://localhost:3000') expect(qssService.connected).toBeTruthy() @@ -625,6 +551,94 @@ describe('QSSService', () => { expect(initStatus.qssSetup).toBeTruthy() }) + it('emits the NSE QSS URL from the endpoint passed to connect on iOS after successful sign in', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + await localDbService.setIdentity(userIdentity) + + mockSuccessfulSignIn() + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect('wss://community.example/ws') + await qssService.signInToCommunity(sigchainService.activeChain.team!.id, sigchainService.activeChain) + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'https://community.example/ws', + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + + it('emits the NSE QSS URL from the stored endpoint when connect is called without one on iOS after successful sign in', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + await localDbService.setIdentity(userIdentity) + + qssService._qssEndpoint = 'ws://configured.example/ws' + mockSuccessfulSignIn() + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect(undefined) + await qssService.signInToCommunity(sigchainService.activeChain.team!.id, sigchainService.activeChain) + + expect(emitSpy).toHaveBeenCalledWith(SocketEvents.NSE_QSS_URL_UPDATED, { + teamId: 'team-id', + qssUrl: 'http://configured.example/ws', + }) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + + it('skips NSE QSS URL emission when sign in uses a non-ws endpoint on iOS', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { value: 'ios' }) + + try { + await localDbService.setCommunity({ + ...community, + teamId: 'team-id', + qssEnabled: true, + }) + await localDbService.setCurrentCommunityId(community.id) + await localDbService.setIdentity(userIdentity) + + mockSuccessfulSignIn() + mockedAllowed = jest.spyOn(qssService, 'qssAllowed', 'get').mockReturnValue(true) + const emitSpy = jest.spyOn(qssService['socketService'].serverIoProvider.io, 'emit') + + await qssService.connect('https://community.example/api') + await qssService.signInToCommunity(sigchainService.activeChain.team!.id, sigchainService.activeChain) + + expect(emitSpy).not.toHaveBeenCalledWith( + SocketEvents.NSE_QSS_URL_UPDATED, + expect.objectContaining({ teamId: 'team-id' }) + ) + } finally { + Object.defineProperty(process, 'platform', { value: originalPlatform }) + } + }) + it(`doesn't sign in to community when QSS is not connected`, async () => { await initCommunity() const initStatusOrig = await qssService.getQssInitStatus() From 438d5f98d57a8e1f8ddfc4d9dac052b4898d8097 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 22:00:00 -0400 Subject: [PATCH 83/92] Publish - @quiet/desktop@7.1.0-alpha.9 - @quiet/mobile@7.1.0-alpha.9 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..715dedfe5d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.9](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.8...@quiet/desktop@7.1.0-alpha.9) (2026-04-23) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index a62d51fa07..fab8f95d03 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2b8a0d91ac..e92c918e3c 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..a0daecf23b 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.9](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.8...@quiet/mobile@7.1.0-alpha.9) (2026-04-23) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index 2c77c39fc7..cbf9f2be67 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 589 - versionName "7.1.0-alpha.8" + versionCode 590 + versionName "7.1.0-alpha.9" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 2d7a80b4ab..7db5919a2b 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 542 + 543 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 2f2b6fc29d..940d1eb3c9 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 22 + 23 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index e6938dc4e0..d31e7487a7 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 536 + 537 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 909d02ee21..1d7eee3a45 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 6db365e242..3d1487c3cf 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.8", + "version": "7.1.0-alpha.9", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From ba07feb503f4d1778a172cae3681c28475235fa7 Mon Sep 17 00:00:00 2001 From: taea Date: Wed, 22 Apr 2026 22:00:15 -0400 Subject: [PATCH 84/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 715dedfe5d..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.9](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.8...@quiet/desktop@7.1.0-alpha.9) (2026-04-23) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index a0daecf23b..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.9](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.8...@quiet/mobile@7.1.0-alpha.9) (2026-04-23) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 950c38699944785eb2f62eb7a22f4d218174315a Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 13:18:31 -0400 Subject: [PATCH 85/92] Ensure .env.staging is used for prerelease builds --- .github/actions/before-build/action.yml | 2 +- .github/workflows/desktop-build.yml | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/actions/before-build/action.yml b/.github/actions/before-build/action.yml index 5f48cbbceb..9db58c881d 100644 --- a/.github/actions/before-build/action.yml +++ b/.github/actions/before-build/action.yml @@ -5,7 +5,7 @@ inputs: description: 'OS name (linux | darwin | win32)' required: true envfile: - description: 'Env file to use (.env.production | .env.development | .env.e2e | .env.e2e.qss)' + description: 'Env file to use (.env.production | .env.staging | .env.development | .env.e2e | .env.e2e.qss)' required: false default: '.env.production' diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index b8a40a5b34..f07923ecc8 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -71,7 +71,7 @@ jobs: uses: ./.github/actions/before-build with: source-path: ${{ matrix.source_path }} - envfile: .env.development + envfile: .env.staging - name: Build desktop preview shell: bash @@ -164,7 +164,6 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" - envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Install libfuse run: sudo apt install libfuse2 @@ -173,6 +172,7 @@ jobs: uses: ./.github/actions/before-build with: source-path: linux + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: "Set electron-builder props" run: echo "ELECTRON_BUILDER_PROPS=-c.publish.bucket=$S3_BUCKET" >> $GITHUB_ENV @@ -253,12 +253,12 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" - envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Before build uses: ./.github/actions/before-build with: source-path: darwin + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: "Remove crud files" run: xattr -crs . @@ -334,12 +334,12 @@ jobs: uses: ./.github/actions/setup-env with: bootstrap-packages: "@quiet/eslint-config,@quiet/logger,@quiet/common,@quiet/types,@quiet/state-manager,@quiet/backend,@quiet/identity,@quiet/desktop,backend-bundle,helia,@quiet/node-common" - envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: Before build uses: ./.github/actions/before-build with: source-path: darwin + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: "Remove crud files" run: xattr -crs . @@ -430,6 +430,7 @@ jobs: uses: ./.github/actions/before-build with: source-path: win32 + envfile: ${{ github.event.release.prerelease && '.env.staging' || '.env.production' }} - name: "Set electron-builder props" shell: bash From 25e6d0d909a220894405e9c0f87901627860305f Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 14:10:19 -0400 Subject: [PATCH 86/92] Publish - @quiet/desktop@7.1.0-alpha.10 - @quiet/mobile@7.1.0-alpha.10 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..b3227d1323 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.10](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.9...@quiet/desktop@7.1.0-alpha.10) (2026-04-23) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index fab8f95d03..87675a5f58 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e92c918e3c..71db19712d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..b1ed7825de 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.10](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.9...@quiet/mobile@7.1.0-alpha.10) (2026-04-23) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index cbf9f2be67..f22dbe3cac 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 590 - versionName "7.1.0-alpha.9" + versionCode 591 + versionName "7.1.0-alpha.10" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 7db5919a2b..8e6f7112c7 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 543 + 544 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 940d1eb3c9..55fa5d2971 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 23 + 24 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index d31e7487a7..6d56a70ac6 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 537 + 538 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 1d7eee3a45..48ff2cf48e 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 3d1487c3cf..49063e0040 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.9", + "version": "7.1.0-alpha.10", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From f54773ad9355733a952a4a3144a5f952619daea7 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 14:10:33 -0400 Subject: [PATCH 87/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index b3227d1323..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.10](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.9...@quiet/desktop@7.1.0-alpha.10) (2026-04-23) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index b1ed7825de..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.10](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.9...@quiet/mobile@7.1.0-alpha.10) (2026-04-23) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0] From 1da7a33cb2869b2b730e5a4429be419c22fa35b0 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 19:52:16 -0400 Subject: [PATCH 88/92] Add retryable NSE auth fetches with fresh network sessions --- .../NSEAuthService.swift | 55 +++++++++++-- .../NSEModels.swift | 51 ++++++++++++ .../NSENetworkClient.swift | 9 ++- .../NotificationService.swift | 78 +++++++++++++++---- 4 files changed, 168 insertions(+), 25 deletions(-) diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift index 21c4ed417c..907784120e 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEAuthService.swift @@ -3,21 +3,63 @@ import os.log private let authLog = OSLog(subsystem: "com.quietmobile.QuietNotificationServiceExtension", category: "NSEAuthService") +struct NSEAuthTokenCacheKey: Hashable { + let qssUrl: URL + let teamId: String +} + +final class NSEAuthTokenCache { + private let lock = NSLock() + private var tokens: [NSEAuthTokenCacheKey: (token: String, expiry: Date)] = [:] + + func token(for qssUrl: URL, teamId: String, now: Date = Date()) -> (token: String, expiry: Date)? { + let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId) + lock.lock() + defer { lock.unlock() } + + guard let cached = tokens[key] else { + return nil + } + + guard cached.expiry > now else { + tokens.removeValue(forKey: key) + return nil + } + + return cached + } + + func store(token: String, expiresIn: Int, for qssUrl: URL, teamId: String, now: Date = Date()) { + let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId) + let expiry = now.addingTimeInterval(TimeInterval(expiresIn) - 30) + lock.lock() + tokens[key] = (token: token, expiry: expiry) + lock.unlock() + } + + func removeToken(for qssUrl: URL, teamId: String) { + let key = NSEAuthTokenCacheKey(qssUrl: qssUrl, teamId: teamId) + lock.lock() + tokens.removeValue(forKey: key) + lock.unlock() + } +} + class NSEAuthService { private let client: NSENetworkClient private let crypto: DeviceCryptography + private let tokenCache: NSEAuthTokenCache - private var tokenCache: [String: (token: String, expiry: Date)] = [:] - - init(client: NSENetworkClient, crypto: DeviceCryptography) { + init(client: NSENetworkClient, crypto: DeviceCryptography, tokenCache: NSEAuthTokenCache = NSEAuthTokenCache()) { self.client = client self.crypto = crypto + self.tokenCache = tokenCache } // MARK: - Full auth flow func authenticate(deviceId: String, teamId: String) async throws -> String { - if let cached = tokenCache[teamId], cached.expiry > Date() { + if let cached = tokenCache.token(for: client.baseURL, teamId: teamId) { os_log("authenticate: using cached token for teamId=%{public}@, expires=%{public}@", log: authLog, type: .debug, teamId, "\(cached.expiry)") return cached.token @@ -42,7 +84,7 @@ class NSEAuthService { ) os_log("authenticate: token received, expiresIn=%{public}d", log: authLog, type: .info, tokenResp.expiresIn) - tokenCache[teamId] = (token: tokenResp.token, expiry: Date().addingTimeInterval(TimeInterval(tokenResp.expiresIn) - 30)) + tokenCache.store(token: tokenResp.token, expiresIn: tokenResp.expiresIn, for: client.baseURL, teamId: teamId) return tokenResp.token } @@ -63,7 +105,7 @@ class NSEAuthService { } catch NSEAuthError.logFetchFailed(let statusCode) where statusCode == 401 { os_log("fetchNewEntries: token rejected (401) for teamId=%{public}@, evicting cache and retrying", log: authLog, type: .info, teamId) - tokenCache.removeValue(forKey: teamId) + tokenCache.removeToken(for: client.baseURL, teamId: teamId) let freshToken = try await authenticate(deviceId: deviceId, teamId: teamId) let resp = try await client.fetchLogEntries(teamId: teamId, afterSeq: afterSeq, token: freshToken) os_log("fetchNewEntries: retry succeeded, received %{public}d entries", log: authLog, type: .info, resp.entries.count) @@ -71,4 +113,3 @@ class NSEAuthService { } } } - diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift index 7e03c083ce..941040517a 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSEModels.swift @@ -28,6 +28,57 @@ enum NSEAuthError: Error, LocalizedError { } } +extension NSEAuthError { + private static let retryableURLCodes: Set = [ + URLError.cannotConnectToHost.rawValue, + URLError.networkConnectionLost.rawValue, + URLError.timedOut.rawValue, + URLError.notConnectedToInternet.rawValue, + URLError.cannotFindHost.rawValue, + URLError.dnsLookupFailed.rawValue, + URLError.resourceUnavailable.rawValue, + URLError.callIsActive.rawValue, + URLError.dataNotAllowed.rawValue + ] + + private static let retryablePOSIXCodes: Set = [53, 57, 60, 61, 64, 65] + + var isRetryableNetworkFailure: Bool { + guard case .networkError(let error) = self else { + return false + } + return Self.isRetryableNetworkFailure(error) + } + + private static func isRetryableNetworkFailure(_ error: Error) -> Bool { + let nsError = error as NSError + + if nsError.domain == NSURLErrorDomain, retryableURLCodes.contains(nsError.code) { + return true + } + + if nsError.domain == NSPOSIXErrorDomain, retryablePOSIXCodes.contains(nsError.code) { + return true + } + + if let streamCode = nsError.userInfo["_kCFStreamErrorCodeKey"] as? Int, + retryablePOSIXCodes.contains(streamCode) { + return true + } + + if let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError { + if underlying.domain == NSURLErrorDomain, retryableURLCodes.contains(underlying.code) { + return true + } + if underlying.domain == NSPOSIXErrorDomain, retryablePOSIXCodes.contains(underlying.code) { + return true + } + } + + return false + } +} + // MARK: - Challenge struct ChallengePayload: Codable { diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift index dd2c1a40b0..fda5c4ebbd 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NSENetworkClient.swift @@ -16,17 +16,18 @@ class NSENetworkClient { private static let decoder = JSONDecoder() - // Dedicated session with tight timeouts suitable for an NSE (30-second budget). - private static let defaultSession: URLSession = { + // Each client gets a fresh session so path transitions cannot poison pooled connections. + private static func makeDefaultSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = 10 config.timeoutIntervalForResource = 20 + config.waitsForConnectivity = true return URLSession(configuration: config) - }() + } init(baseURL: URL, session: URLSession? = nil) { self.baseURL = baseURL - self.session = session ?? NSENetworkClient.defaultSession + self.session = session ?? NSENetworkClient.makeDefaultSession() } // MARK: - POST /nse-auth/challenge diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift index 0a2bac3652..fbee0a6470 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift +++ b/packages/mobile/ios/QuietNotificationServiceExtension/NotificationService.swift @@ -9,12 +9,19 @@ class NotificationService: UNNotificationServiceExtension { let message: NSEDecryptedNotificationMessage } + private static let retryDelaysNanoseconds: [UInt64] = [ + 250_000_000, + 750_000_000 + ] + + private static let maxRetryWindow: TimeInterval = 5 + var contentHandler: ((UNNotificationContent) -> Void)? var bestAttemptContent: UNMutableNotificationContent? var fetchTask: Task? private let crypto = NSECryptoService() - private var authCache: [URL: NSEAuthService] = [:] + private let tokenCache = NSEAuthTokenCache() private static func channelName(from channelId: String) -> String { guard let separatorIndex = channelId.firstIndex(of: "_") else { @@ -76,23 +83,11 @@ class NotificationService: UNNotificationServiceExtension { log: nseLog, type: .info, teamId, qssUrlString) do { - let auth: NSEAuthService - if let cached = authCache[qssUrl] { - os_log("fetchAndUpdate: using cached NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString) - auth = cached - } else { - os_log("fetchAndUpdate: creating new NSEAuthService for %{public}@", log: nseLog, type: .debug, qssUrlString) - let client = NSENetworkClient(baseURL: qssUrl) - let newAuth = NSEAuthService(client: client, crypto: crypto) - authCache[qssUrl] = newAuth - auth = newAuth - } - let afterSeq = SharedDefaults.getLastSyncSeq() os_log("fetchAndUpdate: fetching entries afterSeq=%{public}lld", log: nseLog, type: .info, afterSeq) - let response = try await auth.fetchNewEntries(teamId: teamId, afterSeq: afterSeq) + let response = try await fetchEntriesWithRetry(qssUrl: qssUrl, teamId: teamId, afterSeq: afterSeq) let entries = response.entries let baselineSeq = afterSeq os_log("fetchAndUpdate: fetched %{public}d entries", @@ -189,6 +184,61 @@ class NotificationService: UNNotificationServiceExtension { } } + private func fetchEntriesWithRetry(qssUrl: URL, teamId: String, afterSeq: Int64) async throws -> LogEntriesResponse { + let startedAt = Date() + var retryIndex = 0 + + while true { + guard !Task.isCancelled else { + throw CancellationError() + } + + do { + let auth = makeAuthService(qssUrl: qssUrl) + return try await auth.fetchNewEntries(teamId: teamId, afterSeq: afterSeq) + } catch { + guard shouldRetryFetch(error: error, startedAt: startedAt, retryIndex: retryIndex) else { + throw error + } + + let delay = Self.retryDelaysNanoseconds[retryIndex] + retryIndex += 1 + os_log( + "fetchAndUpdate: retrying full auth fetch after transient network error (%{public}d/%{public}d): %{public}@", + log: nseLog, + type: .info, + retryIndex, + Self.retryDelaysNanoseconds.count, + String(describing: error) + ) + try await Task.sleep(nanoseconds: delay) + } + } + } + + private func makeAuthService(qssUrl: URL) -> NSEAuthService { + os_log("fetchAndUpdate: creating fresh NSEAuthService for %{public}@", + log: nseLog, type: .debug, qssUrl.absoluteString) + let client = NSENetworkClient(baseURL: qssUrl) + return NSEAuthService(client: client, crypto: crypto, tokenCache: tokenCache) + } + + private func shouldRetryFetch(error: Error, startedAt: Date, retryIndex: Int) -> Bool { + guard retryIndex < Self.retryDelaysNanoseconds.count else { + return false + } + + guard Date().timeIntervalSince(startedAt) < Self.maxRetryWindow else { + return false + } + + guard let authError = error as? NSEAuthError else { + return false + } + + return authError.isRetryableNetworkFailure + } + private func applyNotificationMessage(_ message: NSEDecryptedNotificationMessage, to content: UNMutableNotificationContent) { content.title = "#\(Self.channelName(from: message.channelId))" content.body = message.body From 09390fe656df2d1127afe1027db2215565e41dba Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 19:55:33 -0400 Subject: [PATCH 89/92] Fix ENVFILE reference in .env.staging for desktop and mobile configurations --- packages/desktop/.env.staging | 2 +- packages/mobile/.env.staging | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/desktop/.env.staging b/packages/desktop/.env.staging index e6d88b4329..47415e0ed4 100644 --- a/packages/desktop/.env.staging +++ b/packages/desktop/.env.staging @@ -4,5 +4,5 @@ QSS_ALLOWED=true QSS_ENDPOINT=ws://qss-lb-d981e6979ef09da9.elb.us-east-1.amazonaws.com:80 QPS_ALLOWED=true LOG_TO_FILE=true -ENVFILE=.env.development +ENVFILE=.env.staging DEBUG=state-manager*,desktop*,utils*,identity*,backend*,-backend:auth:cryptoService,-backend:Tor*,-backend:TimedQueue diff --git a/packages/mobile/.env.staging b/packages/mobile/.env.staging index 1382197df3..3601e7ad77 100644 --- a/packages/mobile/.env.staging +++ b/packages/mobile/.env.staging @@ -5,3 +5,4 @@ COLORIZE=false QSS_ALLOWED=true QSS_ENDPOINT=ws://qss-lb-d981e6979ef09da9.elb.us-east-1.amazonaws.com:80 QPS_ALLOWED=true +ENVFILE=.env.staging From 5e144a1116f926461297503a2157076ec4f59358 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 19:58:08 -0400 Subject: [PATCH 90/92] Add check for team membership before pulling log entries in QSSService Co-authored-by: Copilot --- packages/backend/src/nest/qss/qss.service.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/backend/src/nest/qss/qss.service.ts b/packages/backend/src/nest/qss/qss.service.ts index 190c1d2b83..d8f1431894 100644 --- a/packages/backend/src/nest/qss/qss.service.ts +++ b/packages/backend/src/nest/qss/qss.service.ts @@ -982,6 +982,17 @@ export class QSSService extends EventEmitter implements OnModuleDestroy, OnModul let nextStartSeq = await this.localDbService.getLastSyncSeq(teamId) const sigchain = this.sigChainService.getChain({ teamId }) const userId = sigchain.context.user.userId + if (!sigchain.roles.amIMemberOfRole(RoleName.MEMBER)) { + this.logger.warn(`User is not a member of team ${teamId}, skipping log entry pull until full join`) + return { + ts: DateTime.utc().toMillis(), + status: CommunityOperationStatus.UNAUTHORIZED, + payload: { + hasNextPage: false, + entries: [], + }, + } + } let hasNextPage = true let page = 0 From d331a2bc30a15fa7794c6eee7ac21114d983564f Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 20:06:32 -0400 Subject: [PATCH 91/92] Publish - @quiet/desktop@7.1.0-alpha.11 - @quiet/mobile@7.1.0-alpha.11 --- packages/desktop/CHANGELOG.md | 13 +++++++++++++ packages/desktop/package-lock.json | 4 ++-- packages/desktop/package.json | 2 +- packages/mobile/CHANGELOG.md | 13 +++++++++++++ packages/mobile/android/app/build.gradle | 4 ++-- packages/mobile/ios/Quiet/Info.plist | 2 +- .../QuietNotificationServiceExtension/Info.plist | 2 +- packages/mobile/ios/QuietTests/Info.plist | 2 +- packages/mobile/package-lock.json | 4 ++-- packages/mobile/package.json | 2 +- 10 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 0463edc68d..3d64e176c7 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.11](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.10...@quiet/desktop@7.1.0-alpha.11) (2026-04-24) + +**Note:** Version bump only for package @quiet/desktop + + + + + # Changelog ## [7.1.0] diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 87675a5f58..c9d6d25e07 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/desktop", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@quiet/desktop", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "license": "GPL-3.0-or-later", "dependencies": { "@dotenvx/dotenvx": "1.39.0", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 71db19712d..177923d6b4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -79,7 +79,7 @@ }, "homepage": "https://github.com/TryQuiet", "@comment version": "To build new version for specific platform, just replace platform in version tag to one of following linux, mac, windows", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "description": "Decentralized team chat", "main": "dist/main/main.js", "scripts": { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 0463edc68d..46116cc115 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,3 +1,16 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [7.1.0-alpha.11](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.10...@quiet/mobile@7.1.0-alpha.11) (2026-04-24) + +**Note:** Version bump only for package @quiet/mobile + + + + + # Changelog ## [7.1.0] diff --git a/packages/mobile/android/app/build.gradle b/packages/mobile/android/app/build.gradle index f22dbe3cac..30d21c26ac 100644 --- a/packages/mobile/android/app/build.gradle +++ b/packages/mobile/android/app/build.gradle @@ -168,8 +168,8 @@ android { applicationId = "com.quietmobile" minSdkVersion(rootProject.ext.minSdkVersion) targetSdkVersion(rootProject.ext.targetSdkVersion) - versionCode 591 - versionName "7.1.0-alpha.10" + versionCode 592 + versionName "7.1.0-alpha.11" resValue("string", "build_config_package", "com.quietmobile") testBuildType = System.getProperty("testBuildType", "debug") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/packages/mobile/ios/Quiet/Info.plist b/packages/mobile/ios/Quiet/Info.plist index 8e6f7112c7..8902dd5409 100644 --- a/packages/mobile/ios/Quiet/Info.plist +++ b/packages/mobile/ios/Quiet/Info.plist @@ -34,7 +34,7 @@ CFBundleVersion - 544 + 545 ITSAppUsesNonExemptEncryption LSRequiresIPhoneOS diff --git a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist index 55fa5d2971..ba55b200f6 100644 --- a/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist +++ b/packages/mobile/ios/QuietNotificationServiceExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 7.1.0 CFBundleVersion - 24 + 25 FirebaseAppDelegateProxyEnabled NSAppTransportSecurity diff --git a/packages/mobile/ios/QuietTests/Info.plist b/packages/mobile/ios/QuietTests/Info.plist index 6d56a70ac6..0608346334 100644 --- a/packages/mobile/ios/QuietTests/Info.plist +++ b/packages/mobile/ios/QuietTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 538 + 539 diff --git a/packages/mobile/package-lock.json b/packages/mobile/package-lock.json index 48ff2cf48e..5e223da4d6 100644 --- a/packages/mobile/package-lock.json +++ b/packages/mobile/package-lock.json @@ -1,12 +1,12 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@quiet/mobile", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "dependencies": { "@d11/react-native-fast-image": "8.11.1", "@hcaptcha/react-native-hcaptcha": "^2.1.0", diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 49063e0040..8c3c2a9a50 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@quiet/mobile", - "version": "7.1.0-alpha.10", + "version": "7.1.0-alpha.11", "scripts": { "build": "tsc -p tsconfig.build.json --noEmit", "storybook-android": "react-native run-android --mode=storybookDebug --appIdSuffix=storybook.debug", From 16189665102384f7e12ec387cccabb83d51f5715 Mon Sep 17 00:00:00 2001 From: taea Date: Thu, 23 Apr 2026 20:06:50 -0400 Subject: [PATCH 92/92] Update packages CHANGELOG.md --- packages/desktop/CHANGELOG.md | 13 ------------- packages/mobile/CHANGELOG.md | 13 ------------- 2 files changed, 26 deletions(-) diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 3d64e176c7..0463edc68d 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.11](https://github.com/TryQuiet/quiet/compare/@quiet/desktop@7.1.0-alpha.10...@quiet/desktop@7.1.0-alpha.11) (2026-04-24) - -**Note:** Version bump only for package @quiet/desktop - - - - - # Changelog ## [7.1.0] diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 46116cc115..0463edc68d 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -1,16 +1,3 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -# [7.1.0-alpha.11](https://github.com/TryQuiet/quiet/compare/@quiet/mobile@7.1.0-alpha.10...@quiet/mobile@7.1.0-alpha.11) (2026-04-24) - -**Note:** Version bump only for package @quiet/mobile - - - - - # Changelog ## [7.1.0]