From 11c4e62cd1fe0391a31e3ed1b2a91fb90fd20c00 Mon Sep 17 00:00:00 2001 From: Quarter <1807900+onefovrth@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:13:10 -0800 Subject: [PATCH 1/8] Remove 'anonymous' terminology from UI copy - Update Quickname detail copy from 'You always start anonymous' to 'You control how you show up' - Simplify join event message to always say 'You joined as [Name]' instead of 'You joined anonymously as [Name]' --- .../Conversation Detail Drawer/WhatIsQuicknameView.swift | 2 +- ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Convos/Conversation Detail/Conversation Detail Drawer/WhatIsQuicknameView.swift b/Convos/Conversation Detail/Conversation Detail Drawer/WhatIsQuicknameView.swift index fed166c8e..82cea2ca8 100644 --- a/Convos/Conversation Detail/Conversation Detail Drawer/WhatIsQuicknameView.swift +++ b/Convos/Conversation Detail/Conversation Detail Drawer/WhatIsQuicknameView.swift @@ -13,7 +13,7 @@ struct WhatIsQuicknameView: View { Text("You're a new you in every convo") .font(.system(.largeTitle)) .fontWeight(.bold) - Text("Choose to add a name or pic, or not. You always start anonymous.") + Text("Choose to add a name or pic, or not. You control how you show up.") .font(.body) .foregroundStyle(.colorTextPrimary) diff --git a/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift b/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift index 4bcac7254..aebe09925 100644 --- a/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift +++ b/ConvosCore/Sources/ConvosCore/Mocks/ModelMocks.swift @@ -161,7 +161,7 @@ public extension ConversationUpdate { } else if !addedMembers.isEmpty { if addedMembers.count == 1, let member = addedMembers.first, member.isCurrentUser { - let asString = member.profile.name != nil ? "as \(member.profile.displayName)" : "anonymously as \(member.profile.displayName)" + let asString = "as \(member.profile.displayName)" return "You joined \(asString)" } return "\(addedMembers.formattedNamesString) joined by invitation" From 6f5ccf238063c1bf94d037e48a97b0bc1574ddb8 Mon Sep 17 00:00:00 2001 From: Jarod Luebbert Date: Tue, 10 Feb 2026 16:18:01 -0800 Subject: [PATCH 2/8] Bump build number to 2 for 1.0.6 hotfix resubmission --- Convos.xcodeproj/project.pbxproj | 48 ++++++++++++++++---------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/Convos.xcodeproj/project.pbxproj b/Convos.xcodeproj/project.pbxproj index f6dc3b35d..be0959916 100644 --- a/Convos.xcodeproj/project.pbxproj +++ b/Convos.xcodeproj/project.pbxproj @@ -1001,7 +1001,7 @@ CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; @@ -1064,7 +1064,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1102,7 +1102,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1169,7 +1169,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1217,7 +1217,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1240,7 +1240,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1265,7 +1265,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1290,7 +1290,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1318,7 +1318,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1363,7 +1363,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1405,7 +1405,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1430,7 +1430,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1454,7 +1454,7 @@ baseConfigurationReferenceRelativePath = Config/Dev.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1478,7 +1478,7 @@ baseConfigurationReferenceRelativePath = Config/Prod.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1503,7 +1503,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1533,7 +1533,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1565,7 +1565,7 @@ CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_CODE_COVERAGE = YES; ENABLE_TESTABILITY = YES; @@ -1611,7 +1611,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Prod.plist; @@ -1851,7 +1851,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Local.plist; @@ -1891,7 +1891,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1921,7 +1921,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1949,7 +1949,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1991,7 +1991,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2015,7 +2015,7 @@ baseConfigurationReferenceRelativePath = Config/Local.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; From 1f5c2551976071955c324fd2dbb929c8b2372699 Mon Sep 17 00:00:00 2001 From: Jarod Luebbert Date: Thu, 19 Feb 2026 14:19:58 -0800 Subject: [PATCH 3/8] Raise FD soft limit to 512 for users with many conversations The default iOS soft limit of 256 file descriptors is insufficient for users with ~50 conversations, as each XMTP conversation requires database connections and sockets. Raises the soft limit to 512 at app launch before any XMTP clients are created. Also bumps build number to 3 for hotfix resubmission. --- Convos.xcodeproj/project.pbxproj | 48 +++++++++--------- Convos/ConvosApp.swift | 2 + .../Utilities/FileDescriptorDiagnostics.swift | 24 +++++++++ qa/cxdb/qa.sqlite | Bin 0 -> 110592 bytes qa/cxdb/qa.sqlite-shm | Bin 0 -> 32768 bytes qa/cxdb/qa.sqlite-wal | 0 6 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 qa/cxdb/qa.sqlite create mode 100644 qa/cxdb/qa.sqlite-shm create mode 100644 qa/cxdb/qa.sqlite-wal diff --git a/Convos.xcodeproj/project.pbxproj b/Convos.xcodeproj/project.pbxproj index be0959916..a56258134 100644 --- a/Convos.xcodeproj/project.pbxproj +++ b/Convos.xcodeproj/project.pbxproj @@ -1001,7 +1001,7 @@ CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; @@ -1064,7 +1064,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1102,7 +1102,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1169,7 +1169,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1217,7 +1217,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1240,7 +1240,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1265,7 +1265,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1290,7 +1290,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1318,7 +1318,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1363,7 +1363,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1405,7 +1405,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1430,7 +1430,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1454,7 +1454,7 @@ baseConfigurationReferenceRelativePath = Config/Dev.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1478,7 +1478,7 @@ baseConfigurationReferenceRelativePath = Config/Prod.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1503,7 +1503,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1533,7 +1533,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1565,7 +1565,7 @@ CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_CODE_COVERAGE = YES; ENABLE_TESTABILITY = YES; @@ -1611,7 +1611,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Prod.plist; @@ -1851,7 +1851,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Local.plist; @@ -1891,7 +1891,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1921,7 +1921,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -1949,7 +1949,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1991,7 +1991,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; @@ -2015,7 +2015,7 @@ baseConfigurationReferenceRelativePath = Config/Local.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; diff --git a/Convos/ConvosApp.swift b/Convos/ConvosApp.swift index 5b7ad4652..beb817d11 100644 --- a/Convos/ConvosApp.swift +++ b/Convos/ConvosApp.swift @@ -13,6 +13,8 @@ struct ConvosApp: App { let quicknameViewModel: QuicknameSettingsViewModel = .shared init() { + FileDescriptorDiagnostics.raiseSoftLimit(to: 512) + let environment = ConfigManager.shared.currentEnvironment // Configure logging (automatically disabled in production) ConvosLog.configure(environment: environment) diff --git a/ConvosCore/Sources/ConvosCore/Utilities/FileDescriptorDiagnostics.swift b/ConvosCore/Sources/ConvosCore/Utilities/FileDescriptorDiagnostics.swift index cdc942d87..630d61bec 100644 --- a/ConvosCore/Sources/ConvosCore/Utilities/FileDescriptorDiagnostics.swift +++ b/ConvosCore/Sources/ConvosCore/Utilities/FileDescriptorDiagnostics.swift @@ -43,6 +43,30 @@ public enum FileDescriptorDiagnostics { return (soft: limits.rlim_cur, hard: limits.rlim_max) } + /// Raises the soft file descriptor limit to the given value (clamped to the hard limit). + /// Call early in app launch before XMTP clients are created. + @discardableResult + public static func raiseSoftLimit(to newLimit: UInt64 = 512) -> Bool { + var limits = rlimit() + guard getrlimit(RLIMIT_NOFILE, &limits) == 0 else { + os_log(.error, log: logger, "Failed to get current file descriptor limits") + return false + } + let target = min(newLimit, limits.rlim_max) + let previous = limits.rlim_cur + if previous >= target { + os_log(.default, log: logger, "FD soft limit already at %llu (requested %llu)", previous, newLimit) + return true + } + limits.rlim_cur = target + guard setrlimit(RLIMIT_NOFILE, &limits) == 0 else { + os_log(.error, log: logger, "Failed to raise FD soft limit to %llu", target) + return false + } + os_log(.default, log: logger, "Raised FD soft limit from %llu to %llu", previous, target) + return true + } + /// Logs current file descriptor usage using os_log (visible in production) public static func logUsage(context: String = "") { let count = openFileDescriptorCount() diff --git a/qa/cxdb/qa.sqlite b/qa/cxdb/qa.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..cac8b42196047be60dfa17dc9eec732bd3038260 GIT binary patch literal 110592 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 literal 0 HcmV?d00001 diff --git a/qa/cxdb/qa.sqlite-shm b/qa/cxdb/qa.sqlite-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3 Date: Thu, 19 Feb 2026 14:48:43 -0800 Subject: [PATCH 4/8] Bump version to 1.0.7 (build 1) The 1.0.6 train is closed on App Store Connect. Bumps marketing version to 1.0.7 and resets build number to 1 for the hotfix submission. --- Convos.xcodeproj/project.pbxproj | 96 ++++++++++++++++---------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/Convos.xcodeproj/project.pbxproj b/Convos.xcodeproj/project.pbxproj index a56258134..bd22ab921 100644 --- a/Convos.xcodeproj/project.pbxproj +++ b/Convos.xcodeproj/project.pbxproj @@ -1001,7 +1001,7 @@ CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; @@ -1034,7 +1034,7 @@ "@executable_path/Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1064,7 +1064,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1078,7 +1078,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = org.convos.KeychainIdentityStoreExample; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -1102,7 +1102,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = KeychainIdentityStoreExample/KeychainIdentityStoreExample.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1116,7 +1116,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = org.convos.KeychainIdentityStoreExample; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -1169,7 +1169,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1192,7 +1192,7 @@ GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1217,11 +1217,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = org.convos.KeychainIdentityStoreExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1240,11 +1240,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = org.convos.KeychainIdentityStoreExampleTests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1265,11 +1265,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1290,11 +1290,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1318,7 +1318,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1335,7 +1335,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1363,7 +1363,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1380,7 +1380,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1405,11 +1405,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1430,11 +1430,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1454,11 +1454,11 @@ baseConfigurationReferenceRelativePath = Config/Dev.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1478,11 +1478,11 @@ baseConfigurationReferenceRelativePath = Config/Prod.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -1503,7 +1503,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1514,7 +1514,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1533,7 +1533,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1544,7 +1544,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1565,7 +1565,7 @@ CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_CODE_COVERAGE = YES; ENABLE_TESTABILITY = YES; @@ -1584,7 +1584,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1611,7 +1611,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Prod.plist; @@ -1628,7 +1628,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1851,7 +1851,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Convos/Convos.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Convos/Config/Info.Local.plist; @@ -1868,7 +1868,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1891,7 +1891,7 @@ buildSettings = { CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = NotificationService/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = NotificationService; @@ -1902,7 +1902,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1921,11 +1921,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1949,7 +1949,7 @@ CODE_SIGN_ENTITLEMENTS = ConvosAppClip/ConvosAppClip.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -1966,7 +1966,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1991,11 +1991,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(CONVOS_APP_CLIP_TESTS_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -2015,11 +2015,11 @@ baseConfigurationReferenceRelativePath = Config/Local.xcconfig; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = FY4NZR34Z3; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 26.0; - MARKETING_VERSION = 1.0.6; + MARKETING_VERSION = 1.0.7; PRODUCT_BUNDLE_IDENTIFIER = "$(NOTIFICATION_SERVICE_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; From 2088382cb7df86f0e8d746f7742ea092c3656b54 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Fri, 20 Feb 2026 12:45:46 -0800 Subject: [PATCH 5/8] Cap pending invite inboxes, expire stale ones, and add debug view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add maxAwakePendingInvites (default 3) to limit how many pending invite XMTP clients are active simultaneously. Previously, all pending invite inboxes were force-woken on app launch regardless of the maxAwakeInboxes limit, which could result in 50+ active clients for users who scanned many invite codes without approval. - Detect stale pending invites older than 7 days on app launch. Automatic deletion is temporarily disabled — stale invites are logged but not removed, pending validation via the debug view. - Only consider invites with <= 1 conversation member as deletable. Invites where the user has actually been added to the group (more than 1 member) are never deleted, preventing accidental data loss. Additionally, inboxes with any other conversations are preserved. - Update sleep() to allow sleeping over-cap pending invite inboxes while still protecting inboxes under the cap. - Update attemptWake() to enforce the pending invite cap when both the pending cap and overall capacity are exceeded. - Add PendingInviteDebugView accessible from Settings (temporarily visible in production) showing all pending invites with creation date, wait duration, member count, and client/inbox IDs. Expired invites (> 7 days) are highlighted in a separate section with a hold-to-delete button that fully cleans up the inbox record, keychain identity, and lifecycle manager tracking. - Inbox and keychain deletion failures are caught individually so one failure does not prevent cleanup of remaining invites. - Add PendingInviteDetail model (with memberCount) and repository methods for the debug view and stale invite queries. - Add 17 new tests: 6 for pending invite cap behavior, 4 for stale expiry detection, and 7 for deleteExpiredPendingInvites covering basic deletion, multi-member protection, other-conversations protection, recent invite preservation, lifecycle manager cleanup, partial failure resilience, and empty state. --- Convos.xcodeproj/project.pbxproj | 1 + Convos/App Settings/AppSettingsView.swift | 10 + .../ConversationsView.swift | 1 + .../ConversationsViewModel.swift | 2 +- .../Debug View/PendingInviteDebugView.swift | 208 ++++++++++ .../Inboxes/InboxLifecycleManager.swift | 117 ++++-- .../Inboxes/MockInboxesService.swift | 10 + .../ConvosCore/Sessions/SessionManager.swift | 85 ++++ .../Sessions/SessionManagerProtocol.swift | 5 + .../PendingInviteRepository.swift | 88 ++++ .../DeleteExpiredPendingInvitesTests.swift | 305 ++++++++++++++ .../InboxLifecycleManagerTests.swift | 383 ++++++++++++++++++ 12 files changed, 1187 insertions(+), 28 deletions(-) create mode 100644 Convos/Debug View/PendingInviteDebugView.swift create mode 100644 ConvosCore/Tests/ConvosCoreTests/DeleteExpiredPendingInvitesTests.swift diff --git a/Convos.xcodeproj/project.pbxproj b/Convos.xcodeproj/project.pbxproj index bd22ab921..baa540a59 100644 --- a/Convos.xcodeproj/project.pbxproj +++ b/Convos.xcodeproj/project.pbxproj @@ -245,6 +245,7 @@ ConvosAppDelegate.swift, "Debug View/DebugExportView.swift", "Debug View/DebugView.swift", + "Debug View/PendingInviteDebugView.swift", DeepLinking/DeepLinkHandler.swift, "Design System/DesignConstants.swift", "Design System/Styles/ButtonStyles.swift", diff --git a/Convos/App Settings/AppSettingsView.swift b/Convos/App Settings/AppSettingsView.swift index d2222d7ab..aea5c9cbe 100644 --- a/Convos/App Settings/AppSettingsView.swift +++ b/Convos/App Settings/AppSettingsView.swift @@ -1,3 +1,4 @@ +import ConvosCore import SwiftUI struct ConvosToolbarButton: View { @@ -28,6 +29,7 @@ struct ConvosToolbarButton: View { struct AppSettingsView: View { @Bindable var viewModel: AppSettingsViewModel @Bindable var quicknameViewModel: QuicknameSettingsViewModel + let session: any SessionManagerProtocol let onDeleteAllData: () -> Void @State private var showingDeleteAllDataConfirmation: Bool = false @Environment(\.openURL) private var openURL: OpenURLAction @@ -142,6 +144,13 @@ struct AppSettingsView: View { Text("Send feedback") } .foregroundStyle(.colorTextPrimary) + + NavigationLink { + PendingInviteDebugView(session: session) + } label: { + Text("Pending Invites") + } + .foregroundStyle(.colorTextPrimary) } header: { HStack { Text("About") @@ -213,6 +222,7 @@ struct AppSettingsView: View { AppSettingsView( viewModel: .mock, quicknameViewModel: quicknameViewModel, + session: MockInboxesService(), onDeleteAllData: {} ) } diff --git a/Convos/Conversations List/ConversationsView.swift b/Convos/Conversations List/ConversationsView.swift index e99293332..2742c4e1c 100644 --- a/Convos/Conversations List/ConversationsView.swift +++ b/Convos/Conversations List/ConversationsView.swift @@ -327,6 +327,7 @@ struct ConversationsView: View { AppSettingsView( viewModel: viewModel.appSettingsViewModel, quicknameViewModel: quicknameViewModel, + session: viewModel.session, onDeleteAllData: viewModel.deleteAllData ) .navigationTransition( diff --git a/Convos/Conversations List/ConversationsViewModel.swift b/Convos/Conversations List/ConversationsViewModel.swift index 8884f48c6..f5a6d1d5d 100644 --- a/Convos/Conversations List/ConversationsViewModel.swift +++ b/Convos/Conversations List/ConversationsViewModel.swift @@ -183,7 +183,7 @@ final class ConversationsViewModel { // MARK: - Private - private let session: any SessionManagerProtocol + let session: any SessionManagerProtocol private let conversationsRepository: any ConversationsRepositoryProtocol private let conversationsCountRepository: any ConversationsCountRepositoryProtocol private var localStateWriters: [String: any ConversationLocalStateWriterProtocol] = [:] diff --git a/Convos/Debug View/PendingInviteDebugView.swift b/Convos/Debug View/PendingInviteDebugView.swift new file mode 100644 index 000000000..6d77b9862 --- /dev/null +++ b/Convos/Debug View/PendingInviteDebugView.swift @@ -0,0 +1,208 @@ +import ConvosCore +import SwiftUI + +struct PendingInviteDebugView: View { + let session: any SessionManagerProtocol + @State private var details: [PendingInviteDetail] = [] + @State private var loadError: String? + @State private var isDeleting: Bool = false + + private var expiredDetails: [PendingInviteDetail] { + let cutoff = Date().addingTimeInterval(-InboxLifecycleManager.stalePendingInviteInterval) + return details.filter { $0.createdAt < cutoff } + } + + private var deletableDetails: [PendingInviteDetail] { + expiredDetails.filter { $0.memberCount <= 1 } + } + + private var activeDetails: [PendingInviteDetail] { + let cutoff = Date().addingTimeInterval(-InboxLifecycleManager.stalePendingInviteInterval) + return details.filter { $0.createdAt >= cutoff } + } + + var body: some View { + List { + Section { + HStack { + Text("Total") + Spacer() + Text("\(details.count)") + .foregroundStyle(.colorTextSecondary) + .font(.system(.body, design: .monospaced)) + } + HStack { + Text("Active") + Spacer() + Text("\(activeDetails.count)") + .foregroundStyle(.colorTextSecondary) + .font(.system(.body, design: .monospaced)) + } + HStack { + Text("Expired (> 7 days)") + Spacer() + Text("\(expiredDetails.count)") + .foregroundStyle(expiredDetails.isEmpty ? .colorTextSecondary : .red) + .font(.system(.body, design: .monospaced)) + } + HStack { + Text("Deletable (expired, ≤ 1 member)") + Spacer() + Text("\(deletableDetails.count)") + .foregroundStyle(deletableDetails.isEmpty ? .colorTextSecondary : .red) + .font(.system(.body, design: .monospaced)) + } + } header: { + Text("Summary") + } + + if let loadError { + Section { + Text(loadError) + .foregroundStyle(.red) + } + } + + if details.isEmpty && loadError == nil { + Section { + Text("No pending invites found") + .foregroundStyle(.colorTextSecondary) + } + } + + if !expiredDetails.isEmpty { + Section { + ForEach(expiredDetails) { detail in + inviteRow(detail: detail) + } + } header: { + Label("Expired", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + if !activeDetails.isEmpty { + Section { + ForEach(activeDetails) { detail in + inviteRow(detail: detail) + } + } header: { + Text("Active") + } + } + } + .safeAreaInset(edge: .bottom) { + if !deletableDetails.isEmpty { + deleteExpiredButton + .padding(.horizontal, DesignConstants.Spacing.step4x) + .padding(.bottom, DesignConstants.Spacing.step2x) + } + } + .navigationTitle("Pending Invites") + .toolbarTitleDisplayMode(.inline) + .task { + loadDetails() + } + .refreshable { + loadDetails() + } + } + + private var deleteExpiredButton: some View { + Button { + deleteExpired() + } label: { + ZStack { + Text("Hold to delete expired") + .opacity(isDeleting ? 0 : 1) + Text("Deleting...") + .opacity(isDeleting ? 1 : 0) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + } + .disabled(isDeleting) + .buttonStyle(HoldToConfirmPrimitiveStyle(config: { + var config = HoldToConfirmStyleConfig.default + config.duration = 3.0 + config.backgroundColor = .colorCaution + return config + }())) + } + + private func deleteExpired() { + isDeleting = true + Task { + do { + let count = try await session.deleteExpiredPendingInvites() + Log.info("Deleted \(count) expired pending invite(s)") + } catch { + Log.error("Failed to delete expired pending invites: \(error)") + } + loadDetails() + isDeleting = false + } + } + + private func inviteRow(detail: PendingInviteDetail) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(detail.conversationName ?? "Unnamed") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.colorTextPrimary) + + labeledValue("Waiting", waitingDuration(since: detail.createdAt)) + labeledValue("Created", detail.createdAt.formatted( + .dateTime.month(.abbreviated).day().hour().minute() + )) + labeledValue("Client ID", detail.clientId, monospaced: true) + labeledValue("Inbox ID", detail.inboxId, monospaced: true) + labeledValue("Members", "\(detail.memberCount)") + labeledValue("Invite Tag", String(detail.inviteTag.prefix(16)) + "…", monospaced: true) + } + .padding(.vertical, 4) + } + + private func labeledValue(_ label: String, _ value: String, monospaced: Bool = false) -> some View { + HStack { + Text(label) + .font(.caption) + .foregroundStyle(.colorTextTertiary) + .frame(width: 70, alignment: .leading) + Text(value) + .font(monospaced ? .system(.caption, design: .monospaced) : .caption) + .foregroundStyle(.colorTextSecondary) + .textSelection(.enabled) + } + } + + private func loadDetails() { + do { + details = try session.pendingInviteDetails() + loadError = nil + } catch { + loadError = error.localizedDescription + } + } + + private func waitingDuration(since date: Date) -> String { + let interval = max(0, Date().timeIntervalSince(date)) + let hours = Int(interval) / 3600 + let minutes = (Int(interval) % 3600) / 60 + + if hours >= 24 { + let days = hours / 24 + let remainingHours = hours % 24 + return "\(days)d \(remainingHours)h \(minutes)m" + } else if hours > 0 { + return "\(hours)h \(minutes)m" + } else { + return "\(minutes)m" + } + } +} + +#Preview { + NavigationStack { + PendingInviteDebugView(session: MockInboxesService()) + } +} diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift index 39498a5c2..940989d0c 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift @@ -64,6 +64,8 @@ public protocol InboxLifecycleManagerProtocol: Actor { public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { public let maxAwakeInboxes: Int + public let maxAwakePendingInvites: Int + public static let stalePendingInviteInterval: TimeInterval = 7 * 24 * 60 * 60 private var awakeInboxes: [String: any MessagingServiceProtocol] = [:] private var _sleepingClientIds: Set = [] @@ -103,6 +105,7 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { public init( maxAwakeInboxes: Int = 10, + maxAwakePendingInvites: Int = 3, databaseReader: any DatabaseReader, databaseWriter: any DatabaseWriter, identityStore: any KeychainIdentityStoreProtocol, @@ -115,6 +118,7 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { apiClient: (any ConvosAPIClientProtocol)? = nil ) { self.maxAwakeInboxes = maxAwakeInboxes + self.maxAwakePendingInvites = maxAwakePendingInvites self.databaseReader = databaseReader self.databaseWriter = databaseWriter self.identityStore = identityStore @@ -181,7 +185,6 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { return try await attemptWake(clientId: clientId, inboxId: inboxId, reason: reason) } - /// Internal wake that throws if at capacity private func attemptWake(clientId: String, inboxId: String, reason: WakeReason) async throws -> any MessagingServiceProtocol { Log.info("Attempting wake for inbox clientId: \(clientId), reason: \(reason.rawValue)") @@ -191,7 +194,6 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { return existing } - // Fail open: if pending invite check fails, assume true to preserve privilege let hasPendingInvite: Bool do { hasPendingInvite = try pendingInviteRepository.hasPendingInvites(clientId: clientId) @@ -201,8 +203,17 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { } let currentAwakeCount = awakeInboxes.count - // Check capacity - pending invites are always allowed, others must have room - if !hasPendingInvite && currentAwakeCount >= maxAwakeInboxes { + if hasPendingInvite { + let awakePendingCount = countAwakePendingInvites() + if awakePendingCount >= maxAwakePendingInvites && currentAwakeCount >= maxAwakeInboxes { + Log.warning( + "Cannot wake pending invite inbox \(clientId): " + + "pending cap reached (\(awakePendingCount)/\(maxAwakePendingInvites)) " + + "and at overall capacity (\(currentAwakeCount)/\(maxAwakeInboxes))" + ) + throw InboxLifecycleError.wakeCapacityExceeded + } + } else if currentAwakeCount >= maxAwakeInboxes { Log.warning("Cannot wake inbox \(clientId): at capacity (\(currentAwakeCount)/\(maxAwakeInboxes))") throw InboxLifecycleError.wakeCapacityExceeded } @@ -216,13 +227,17 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { return service } + private func countAwakePendingInvites() -> Int { + let pendingIds = (try? pendingInviteRepository.clientIdsWithPendingInvites()) ?? [] + return awakeInboxes.keys.filter { pendingIds.contains($0) }.count + } + public func sleep(clientId: String) async { guard let service = awakeInboxes.removeValue(forKey: clientId) else { Log.info("Inbox not awake, cannot sleep: \(clientId)") return } - // Fail open: if pending invite check fails, assume true to preserve privilege let hasPendingInvite: Bool do { hasPendingInvite = try pendingInviteRepository.hasPendingInvites(clientId: clientId) @@ -231,16 +246,19 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { hasPendingInvite = true } if hasPendingInvite { - Log.warning("Cannot sleep inbox with pending invite: \(clientId), keeping awake") - awakeInboxes[clientId] = service - return + let awakePendingCount = countAwakePendingInvites() + if awakePendingCount < maxAwakePendingInvites { + Log.warning("Cannot sleep inbox with pending invite (under cap): \(clientId), keeping awake") + awakeInboxes[clientId] = service + return + } + Log.info("Pending invite inbox over cap (\(awakePendingCount)/\(maxAwakePendingInvites)), allowing sleep: \(clientId)") } Log.info("Sleeping inbox: \(clientId)") let sleepTime = Date() await service.stop() - // Check if inbox was re-woken during the await (concurrent wake() call) guard awakeInboxes[clientId] == nil else { Log.info("Inbox was re-woken during stop, not marking as sleeping: \(clientId)") return @@ -358,6 +376,8 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { public func initializeOnAppLaunch() async { Log.info("Initializing InboxLifecycleManager on app launch") + await cleanupStalePendingInvites() + do { let allActivities = try activityRepository.allInboxActivities() let allPendingInvites = try pendingInviteRepository.allPendingInvites() @@ -365,18 +385,39 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { Log.info("Found \(allActivities.count) inboxes with activity, \(allPendingInvites.count) with pending invites") - // wake inboxes from activity records - for activity in allActivities { - let hasPendingInvite = allPendingInvites.contains { $0.clientId == activity.clientId && $0.hasPendingInvites } + let pendingInviteClientIds = Set( + allPendingInvites + .filter { $0.hasPendingInvites } + .map { $0.clientId } + ) + var awakePendingInviteCount = 0 - // Pending invites always wake (can exceed maxAwakeInboxes) - // Regular inboxes only wake if we haven't hit capacity - if hasPendingInvite || awakeInboxes.count < maxAwakeInboxes { + for activity in allActivities { + let hasPendingInvite = pendingInviteClientIds.contains(activity.clientId) + + if hasPendingInvite { + if awakePendingInviteCount < maxAwakePendingInvites { + do { + _ = try await attemptWake( + clientId: activity.clientId, + inboxId: activity.inboxId, + reason: .pendingInvite + ) + awakePendingInviteCount += 1 + } catch { + Log.error("Failed to wake pending invite inbox \(activity.clientId): \(error)") + } + } else { + _sleepingClientIds.insert(activity.clientId) + _sleepTimes[activity.clientId] = Date() + Log.info("Pending invite inbox over cap, marked sleeping: \(activity.clientId)") + } + } else if awakeInboxes.count < maxAwakeInboxes { do { _ = try await attemptWake( clientId: activity.clientId, inboxId: activity.inboxId, - reason: hasPendingInvite ? .pendingInvite : .appLaunch + reason: .appLaunch ) } catch { Log.error("Failed to wake inbox \(activity.clientId): \(error)") @@ -388,30 +429,52 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { } } - // wake pending invite inboxes that have no activity record for pendingInvite in allPendingInvites where pendingInvite.hasPendingInvites { let clientId = pendingInvite.clientId let inboxId = pendingInvite.inboxId if !activityClientIds.contains(clientId) && !awakeInboxes.keys.contains(clientId) { - Log.info("Waking pending invite inbox with no activity record: \(clientId)") - do { - _ = try await attemptWake( - clientId: clientId, - inboxId: inboxId, - reason: .pendingInvite - ) - } catch { - Log.error("Failed to wake pending invite inbox \(clientId): \(error)") + if awakePendingInviteCount < maxAwakePendingInvites { + Log.info("Waking pending invite inbox with no activity record: \(clientId)") + do { + _ = try await attemptWake( + clientId: clientId, + inboxId: inboxId, + reason: .pendingInvite + ) + awakePendingInviteCount += 1 + } catch { + Log.error("Failed to wake pending invite inbox \(clientId): \(error)") + } + } else { + _sleepingClientIds.insert(clientId) + _sleepTimes[clientId] = Date() + Log.info("Pending invite inbox (no activity) over cap, marked sleeping: \(clientId)") } } } - Log.info("App launch initialization complete: \(awakeInboxes.count) awake, \(_sleepingClientIds.count) sleeping") + Log.info( + "App launch initialization complete: \(awakeInboxes.count) awake, " + + "\(_sleepingClientIds.count) sleeping, \(awakePendingInviteCount) pending invite inboxes awake" + ) } catch { Log.error("Failed to initialize InboxLifecycleManager: \(error)") } } + private func cleanupStalePendingInvites() async { + let cutoff = Date().addingTimeInterval(-Self.stalePendingInviteInterval) + do { + let staleClientIds = try pendingInviteRepository.stalePendingInviteClientIds(olderThan: cutoff) + guard !staleClientIds.isEmpty else { return } + + // TODO: re-enable deletion once we've validated via the debug view in production + Log.info("Found \(staleClientIds.count) stale pending invite(s) older than 7 days (not deleting yet)") + } catch { + Log.error("Failed to check stale pending invites: \(error)") + } + } + public func stopAll() async { Log.info("Stopping all inboxes") diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/MockInboxesService.swift b/ConvosCore/Sources/ConvosCore/Inboxes/MockInboxesService.swift index 2d36def91..567df1a10 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/MockInboxesService.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/MockInboxesService.swift @@ -96,4 +96,14 @@ public final class MockInboxesService: SessionManagerProtocol { public func isInboxSleeping(clientId: String) async -> Bool { false } + + // MARK: - Debug + + public func pendingInviteDetails() throws -> [PendingInviteDetail] { + [] + } + + public func deleteExpiredPendingInvites() async throws -> Int { + 0 + } } diff --git a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift index c0319f61c..82343bd3f 100644 --- a/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift +++ b/ConvosCore/Sources/ConvosCore/Sessions/SessionManager.swift @@ -369,6 +369,91 @@ public final class SessionManager: SessionManagerProtocol, @unchecked Sendable { await lifecycleManager.isSleeping(clientId: clientId) } + // MARK: Debug + + public func pendingInviteDetails() throws -> [PendingInviteDetail] { + let repository = PendingInviteRepository(databaseReader: databaseReader) + return try repository.allPendingInviteDetails() + } + + public func deleteExpiredPendingInvites() async throws -> Int { + let cutoff = Date().addingTimeInterval(-InboxLifecycleManager.stalePendingInviteInterval) + + let expiredInvites: [DBConversation] = try await databaseReader.read { db in + let sql = """ + SELECT c.* + FROM conversation c + WHERE c.id LIKE 'draft-%' + AND c.inviteTag IS NOT NULL + AND length(c.inviteTag) > 0 + AND c.createdAt < ? + AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversationId = c.id) <= 1 + """ + return try DBConversation.fetchAll(db, sql: sql, arguments: [cutoff]) + } + + guard !expiredInvites.isEmpty else { return 0 } + + let expiredConversationIds = Set(expiredInvites.map { $0.id }) + let expiredClientIds = Set(expiredInvites.map { $0.clientId }) + + let clientIdsToKeep: Set = try await databaseReader.read { db in + let placeholders = expiredClientIds.map { _ in "?" }.joined(separator: ",") + let expiredIdPlaceholders = expiredConversationIds.map { _ in "?" }.joined(separator: ",") + + let sql = """ + SELECT DISTINCT c.clientId + FROM conversation c + WHERE c.clientId IN (\(placeholders)) + AND ( + (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversationId = c.id) > 1 + OR c.id NOT IN (\(expiredIdPlaceholders)) + ) + """ + let arguments = StatementArguments(Array(expiredClientIds) + Array(expiredConversationIds)) + return Set(try String.fetchAll(db, sql: sql, arguments: arguments)) + } + + let safeToDeleteClientIds = expiredClientIds.subtracting(clientIdsToKeep) + + let inboxWriter = InboxWriter(dbWriter: databaseWriter) + + for clientId in safeToDeleteClientIds { + await lifecycleManager.forceRemove(clientId: clientId) + + do { + let identity = try await identityStore.delete(clientId: clientId) + Log.info("Deleted keychain identity for expired invite inbox: \(identity.inboxId)") + } catch { + Log.warning("Could not delete keychain identity for clientId \(clientId): \(error)") + } + + do { + try await inboxWriter.delete(clientId: clientId) + Log.info("Deleted inbox record for expired invite clientId: \(clientId)") + } catch { + Log.warning("Could not delete inbox record for clientId \(clientId): \(error)") + } + } + + let safeToDeleteConversationIds = expiredInvites + .filter { safeToDeleteClientIds.contains($0.clientId) } + .map { $0.id } + + let deletedCount = try await databaseWriter.write { db in + try DBConversation + .filter(safeToDeleteConversationIds.contains(DBConversation.Columns.id)) + .deleteAll(db) + } + + if !clientIdsToKeep.isEmpty { + Log.info("Skipped \(clientIdsToKeep.count) inbox(es) with other conversations or multi-member groups") + } + + Log.info("Deleted \(deletedCount) expired pending invite(s), cleaned up \(safeToDeleteClientIds.count) inbox(es)") + return deletedCount + } + // MARK: Helpers public func inboxId(for conversationId: String) async -> String? { diff --git a/ConvosCore/Sources/ConvosCore/Sessions/SessionManagerProtocol.swift b/ConvosCore/Sources/ConvosCore/Sessions/SessionManagerProtocol.swift index 549014ea1..5b8d69ff7 100644 --- a/ConvosCore/Sources/ConvosCore/Sessions/SessionManagerProtocol.swift +++ b/ConvosCore/Sources/ConvosCore/Sessions/SessionManagerProtocol.swift @@ -59,4 +59,9 @@ public protocol SessionManagerProtocol: AnyObject, Sendable { // MARK: Helpers func inboxId(for conversationId: String) async -> String? + + // MARK: Debug + + func pendingInviteDetails() throws -> [PendingInviteDetail] + func deleteExpiredPendingInvites() async throws -> Int } diff --git a/ConvosCore/Sources/ConvosCore/Storage/Repositories/PendingInviteRepository.swift b/ConvosCore/Sources/ConvosCore/Storage/Repositories/PendingInviteRepository.swift index 945d17585..658625b8a 100644 --- a/ConvosCore/Sources/ConvosCore/Storage/Repositories/PendingInviteRepository.swift +++ b/ConvosCore/Sources/ConvosCore/Storage/Repositories/PendingInviteRepository.swift @@ -17,11 +17,42 @@ public struct PendingInviteInfo: Codable, Hashable, Identifiable { } } +public struct PendingInviteDetail: Codable, Hashable, Identifiable, Sendable { + public var id: String { conversationId } + public let conversationId: String + public let clientId: String + public let inboxId: String + public let inviteTag: String + public let conversationName: String? + public let createdAt: Date + public let memberCount: Int + + public init( + conversationId: String, + clientId: String, + inboxId: String, + inviteTag: String, + conversationName: String?, + createdAt: Date, + memberCount: Int = 0 + ) { + self.conversationId = conversationId + self.clientId = clientId + self.inboxId = inboxId + self.inviteTag = inviteTag + self.conversationName = conversationName + self.createdAt = createdAt + self.memberCount = memberCount + } +} + public protocol PendingInviteRepositoryProtocol { func allPendingInvites() throws -> [PendingInviteInfo] func pendingInvites(for clientId: String) throws -> PendingInviteInfo? func clientIdsWithPendingInvites() throws -> Set func hasPendingInvites(clientId: String) throws -> Bool + func allPendingInviteDetails() throws -> [PendingInviteDetail] + func stalePendingInviteClientIds(olderThan cutoff: Date) throws -> Set } public struct PendingInviteRepository: PendingInviteRepositoryProtocol { @@ -69,6 +100,53 @@ public struct PendingInviteRepository: PendingInviteRepositoryProtocol { } } + public func allPendingInviteDetails() throws -> [PendingInviteDetail] { + try databaseReader.read { db in + let sql = """ + SELECT + c.id as conversationId, + c.clientId, + c.inboxId, + c.inviteTag, + c.name, + c.createdAt, + (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversationId = c.id) as memberCount + FROM conversation c + WHERE c.id LIKE 'draft-%' + AND c.inviteTag IS NOT NULL + AND c.inviteTag != '' + ORDER BY c.createdAt ASC + """ + return try Row.fetchAll(db, sql: sql).map { row in + PendingInviteDetail( + conversationId: row["conversationId"], + clientId: row["clientId"], + inboxId: row["inboxId"], + inviteTag: row["inviteTag"], + conversationName: row["name"], + createdAt: row["createdAt"], + memberCount: row["memberCount"] + ) + } + } + } + + public func stalePendingInviteClientIds(olderThan cutoff: Date) throws -> Set { + try databaseReader.read { db in + let sql = """ + SELECT DISTINCT c.clientId + FROM conversation c + WHERE c.id LIKE 'draft-%' + AND c.inviteTag IS NOT NULL + AND c.inviteTag != '' + AND c.createdAt < ? + AND (SELECT COUNT(*) FROM conversation_members cm WHERE cm.conversationId = c.id) <= 1 + """ + let clientIds = try String.fetchAll(db, sql: sql, arguments: [cutoff]) + return Set(clientIds) + } + } + private func fetchAllPendingInvites(db: Database) throws -> [PendingInviteInfo] { let sql = """ SELECT @@ -127,6 +205,8 @@ public struct PendingInviteRepository: PendingInviteRepositoryProtocol { public final class MockPendingInviteRepository: PendingInviteRepositoryProtocol, @unchecked Sendable { public var pendingInvites: [PendingInviteInfo] = [] + public var pendingInviteDetails: [PendingInviteDetail] = [] + public var staleCutoffResult: Set = [] public init() {} @@ -145,4 +225,12 @@ public final class MockPendingInviteRepository: PendingInviteRepositoryProtocol, public func hasPendingInvites(clientId: String) throws -> Bool { pendingInvites.first { $0.clientId == clientId }?.hasPendingInvites ?? false } + + public func allPendingInviteDetails() throws -> [PendingInviteDetail] { + pendingInviteDetails + } + + public func stalePendingInviteClientIds(olderThan cutoff: Date) throws -> Set { + staleCutoffResult + } } diff --git a/ConvosCore/Tests/ConvosCoreTests/DeleteExpiredPendingInvitesTests.swift b/ConvosCore/Tests/ConvosCoreTests/DeleteExpiredPendingInvitesTests.swift new file mode 100644 index 000000000..5da404b5f --- /dev/null +++ b/ConvosCore/Tests/ConvosCoreTests/DeleteExpiredPendingInvitesTests.swift @@ -0,0 +1,305 @@ +@testable import ConvosCore +import Foundation +import GRDB +import Testing + +@Suite("Delete Expired Pending Invites Tests", .serialized) +struct DeleteExpiredPendingInvitesTests { + + private func makeSessionManager( + dbManager: MockDatabaseManager, + identityStore: MockKeychainIdentityStore = MockKeychainIdentityStore(), + lifecycleManager: MockInboxLifecycleManager = MockInboxLifecycleManager() + ) -> SessionManager { + SessionManager( + databaseWriter: dbManager.dbWriter, + databaseReader: dbManager.dbReader, + environment: .tests, + identityStore: identityStore, + lifecycleManager: lifecycleManager, + platformProviders: .mock + ) + } + + private func makeDBConversation( + id: String, + inboxId: String, + clientId: String, + inviteTag: String, + createdAt: Date = Date(), + consent: Consent = .unknown + ) -> DBConversation { + DBConversation( + id: id, + inboxId: inboxId, + clientId: clientId, + clientConversationId: id, + inviteTag: inviteTag, + creatorId: inboxId, + kind: .group, + consent: consent, + createdAt: createdAt, + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil + ) + } + + private func insertInboxAndIdentity( + dbManager: MockDatabaseManager, + identityStore: MockKeychainIdentityStore, + inboxId: String, + clientId: String, + createdAt: Date = Date() + ) async throws { + try await dbManager.dbWriter.write { db in + try DBInbox(inboxId: inboxId, clientId: clientId, createdAt: createdAt).insert(db) + } + let keys = try await identityStore.generateKeys() + _ = try await identityStore.save(inboxId: inboxId, clientId: clientId, keys: keys) + } + + // MARK: - Basic Deletion + + @Test("Deletes expired single-member pending invites") + func testDeletesExpiredInvites() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let lifecycleManager = MockInboxLifecycleManager() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-expired", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: tenDaysAgo + ).insert(db) + } + + let session = makeSessionManager( + dbManager: dbManager, identityStore: identityStore, lifecycleManager: lifecycleManager + ) + + let deleted = try await session.deleteExpiredPendingInvites() + #expect(deleted == 1) + + let conversationCount = try await dbManager.dbReader.read { db in + try DBConversation.fetchCount(db) + } + #expect(conversationCount == 0, "Draft conversation should be deleted") + + let inboxCount = try await dbManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(inboxCount == 0, "Inbox record should be deleted") + + let identities = try await identityStore.loadAll() + #expect(identities.isEmpty, "Keychain identity should be deleted") + } + + // MARK: - Multi-Member Protection + + @Test("Does not delete inbox when conversation has multiple members") + func testSkipsInboxWithMultiMemberConversation() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-expired", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: tenDaysAgo + ).insert(db) + + try DBMember(inboxId: "inbox-1").insert(db) + try DBMember(inboxId: "other-inbox").insert(db) + + try DBConversationMember( + conversationId: "draft-expired", inboxId: "inbox-1", + role: .admin, consent: .allowed, createdAt: tenDaysAgo + ).insert(db) + try DBConversationMember( + conversationId: "draft-expired", inboxId: "other-inbox", + role: .member, consent: .allowed, createdAt: tenDaysAgo + ).insert(db) + } + + let session = makeSessionManager(dbManager: dbManager, identityStore: identityStore) + + let deleted = try await session.deleteExpiredPendingInvites() + #expect(deleted == 0, "Should not delete invite with multiple members") + + let inboxCount = try await dbManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(inboxCount == 1, "Inbox should be preserved") + + let identities = try await identityStore.loadAll() + #expect(identities.count == 1, "Keychain identity should be preserved") + } + + // MARK: - Other Conversations Protection + + @Test("Does not delete inbox when it has other non-expired conversations") + func testSkipsInboxWithOtherConversations() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-expired", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: tenDaysAgo + ).insert(db) + + try makeDBConversation( + id: "real-convo", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "", createdAt: Date(), consent: .allowed + ).insert(db) + } + + let session = makeSessionManager(dbManager: dbManager, identityStore: identityStore) + + let deleted = try await session.deleteExpiredPendingInvites() + + let inboxCount = try await dbManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(inboxCount == 1, "Inbox should be preserved when it has other conversations") + + let identities = try await identityStore.loadAll() + #expect(identities.count == 1, "Keychain identity should be preserved") + + let realConvoExists = try await dbManager.dbReader.read { db in + try DBConversation.filter(DBConversation.Columns.id == "real-convo").fetchCount(db) > 0 + } + #expect(realConvoExists, "Non-expired conversation should still exist") + } + + // MARK: - Recent Invites + + @Test("Does not delete recent pending invites") + func testSkipsRecentInvites() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let twoDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: twoDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-recent", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: twoDaysAgo + ).insert(db) + } + + let session = makeSessionManager(dbManager: dbManager, identityStore: identityStore) + + let deleted = try await session.deleteExpiredPendingInvites() + #expect(deleted == 0) + + let inboxCount = try await dbManager.dbReader.read { db in + try DBInbox.fetchCount(db) + } + #expect(inboxCount == 1, "Inbox should be preserved for recent invite") + } + + // MARK: - Lifecycle Manager Cleanup + + @Test("Removes deleted inbox from lifecycle manager tracking") + func testRemovesFromLifecycleManager() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let lifecycleManager = MockInboxLifecycleManager() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + await lifecycleManager.forceRemove(clientId: "client-1") + await lifecycleManager.sleep(clientId: "client-1") + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-expired", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: tenDaysAgo + ).insert(db) + } + + let session = makeSessionManager( + dbManager: dbManager, identityStore: identityStore, lifecycleManager: lifecycleManager + ) + + _ = try await session.deleteExpiredPendingInvites() + + let isAwake = await lifecycleManager.isAwake(clientId: "client-1") + let isSleeping = await lifecycleManager.isSleeping(clientId: "client-1") + #expect(!isAwake && !isSleeping, "Client should be removed from lifecycle manager") + } + + // MARK: - Partial Failure Resilience + + @Test("Continues cleanup when inbox writer fails for one client") + func testContinuesOnPartialFailure() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let identityStore = MockKeychainIdentityStore() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo + ) + try await insertInboxAndIdentity( + dbManager: dbManager, identityStore: identityStore, + inboxId: "inbox-2", clientId: "client-2", createdAt: tenDaysAgo + ) + try await dbManager.dbWriter.write { db in + try makeDBConversation( + id: "draft-expired-1", inboxId: "inbox-1", clientId: "client-1", + inviteTag: "tag-1", createdAt: tenDaysAgo + ).insert(db) + try makeDBConversation( + id: "draft-expired-2", inboxId: "inbox-2", clientId: "client-2", + inviteTag: "tag-2", createdAt: tenDaysAgo + ).insert(db) + } + + let session = makeSessionManager(dbManager: dbManager, identityStore: identityStore) + + let deleted = try await session.deleteExpiredPendingInvites() + #expect(deleted == 2, "Both expired conversations should be deleted") + } + + // MARK: - Empty State + + @Test("Returns zero when no expired invites exist") + func testNoExpiredInvites() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let session = makeSessionManager(dbManager: dbManager) + + let deleted = try await session.deleteExpiredPendingInvites() + #expect(deleted == 0) + } +} diff --git a/ConvosCore/Tests/ConvosCoreTests/InboxLifecycleManagerTests.swift b/ConvosCore/Tests/ConvosCoreTests/InboxLifecycleManagerTests.swift index 5554a96b7..14997e19c 100644 --- a/ConvosCore/Tests/ConvosCoreTests/InboxLifecycleManagerTests.swift +++ b/ConvosCore/Tests/ConvosCoreTests/InboxLifecycleManagerTests.swift @@ -1171,3 +1171,386 @@ extension InboxLifecycleManager { } } } + +// MARK: - Stale Pending Invite Expiry Tests + +@Suite("InboxLifecycleManager Stale Expiry Tests", .serialized) +struct InboxLifecycleManagerStaleExpiryTests { + + @Test("Stale pending invites are detected but not deleted on app launch") + func testStalePendingInvitesDetectedButNotDeleted() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let eightDaysAgo = Date().addingTimeInterval(-8 * 24 * 60 * 60) + + try await dbManager.dbWriter.write { db in + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: eightDaysAgo).insert(db) + + try makeDBConversation( + id: "draft-stale", + inboxId: "inbox-1", + clientId: "client-1", + inviteTag: "stale-tag", + createdAt: eightDaysAgo + ).insert(db) + } + + let checkRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let hasPendingBefore = try checkRepo.hasPendingInvites(clientId: "client-1") + #expect(hasPendingBefore == true, "Should have pending invite before launch") + + let activityRepo = MockInboxActivityRepository() + let managerRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let manager = InboxLifecycleManager( + maxAwakeInboxes: 10, + databaseReader: dbManager.dbReader, + databaseWriter: dbManager.dbWriter, + identityStore: MockKeychainIdentityStore(), + environment: .tests, + platformProviders: .mock, + activityRepository: activityRepo, + pendingInviteRepository: managerRepo + ) + + await manager.initializeOnAppLaunch() + + let verifyRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let hasPendingAfter = try verifyRepo.hasPendingInvites(clientId: "client-1") + #expect(hasPendingAfter == true, "Stale pending invite should still exist (deletion temporarily disabled)") + } + + @Test("Recent pending invites are not deleted on app launch") + func testRecentPendingInvitesNotDeleted() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let twoDaysAgo = Date().addingTimeInterval(-2 * 24 * 60 * 60) + + try await dbManager.dbWriter.write { db in + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: twoDaysAgo).insert(db) + + try makeDBConversation( + id: "draft-recent", + inboxId: "inbox-1", + clientId: "client-1", + inviteTag: "recent-tag", + createdAt: twoDaysAgo + ).insert(db) + } + + let activityRepo = MockInboxActivityRepository() + activityRepo.activities = [ + InboxActivity(clientId: "client-1", inboxId: "inbox-1", lastActivity: nil, conversationCount: 0) + ] + + let managerRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let manager = InboxLifecycleManager( + maxAwakeInboxes: 10, + databaseReader: dbManager.dbReader, + databaseWriter: dbManager.dbWriter, + identityStore: MockKeychainIdentityStore(), + environment: .tests, + platformProviders: .mock, + activityRepository: activityRepo, + pendingInviteRepository: managerRepo + ) + + await manager.initializeOnAppLaunch() + + let verifyRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let hasPendingAfter = try verifyRepo.hasPendingInvites(clientId: "client-1") + #expect(hasPendingAfter == true, "Recent pending invite should not be deleted") + } + + @Test("stalePendingInviteClientIds correctly identifies stale vs recent invites") + func testStalePendingInviteIdentification() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + let oneDayAgo = Date().addingTimeInterval(-1 * 24 * 60 * 60) + + try await dbManager.dbWriter.write { db in + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo).insert(db) + try DBInbox(inboxId: "inbox-2", clientId: "client-2", createdAt: oneDayAgo).insert(db) + + try makeDBConversation( + id: "draft-stale", + inboxId: "inbox-1", + clientId: "client-1", + inviteTag: "stale-tag", + createdAt: tenDaysAgo + ).insert(db) + + try makeDBConversation( + id: "draft-recent", + inboxId: "inbox-2", + clientId: "client-2", + inviteTag: "recent-tag", + createdAt: oneDayAgo + ).insert(db) + } + + let repo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let cutoff = Date().addingTimeInterval(-InboxLifecycleManager.stalePendingInviteInterval) + let staleIds = try repo.stalePendingInviteClientIds(olderThan: cutoff) + + #expect(staleIds.contains("client-1"), "10-day-old invite should be stale") + #expect(!staleIds.contains("client-2"), "1-day-old invite should not be stale") + } + + @Test("Non-draft conversations are not affected by stale cleanup") + func testNonDraftConversationsNotAffected() async throws { + let dbManager = MockDatabaseManager.makeTestDatabase() + let tenDaysAgo = Date().addingTimeInterval(-10 * 24 * 60 * 60) + + try await dbManager.dbWriter.write { db in + try DBInbox(inboxId: "inbox-1", clientId: "client-1", createdAt: tenDaysAgo).insert(db) + + try makeDBConversation( + id: "convo-real", + inboxId: "inbox-1", + clientId: "client-1", + inviteTag: "some-tag", + createdAt: tenDaysAgo, + consent: .allowed + ).insert(db) + } + + let activityRepo = MockInboxActivityRepository() + activityRepo.activities = [ + InboxActivity(clientId: "client-1", inboxId: "inbox-1", lastActivity: Date(), conversationCount: 1) + ] + + let managerRepo = PendingInviteRepository(databaseReader: dbManager.dbReader) + let manager = InboxLifecycleManager( + maxAwakeInboxes: 10, + databaseReader: dbManager.dbReader, + databaseWriter: dbManager.dbWriter, + identityStore: MockKeychainIdentityStore(), + environment: .tests, + platformProviders: .mock, + activityRepository: activityRepo, + pendingInviteRepository: managerRepo + ) + + await manager.initializeOnAppLaunch() + + let conversationCount = try await dbManager.dbReader.read { db in + try DBConversation.filter(DBConversation.Columns.id == "convo-real").fetchCount(db) + } + #expect(conversationCount == 1, "Real conversation should not be deleted by stale cleanup") + } + + // MARK: - Test Helpers + + func makeDBConversation( + id: String, + inboxId: String, + clientId: String, + inviteTag: String, + createdAt: Date = Date(), + consent: Consent = .unknown + ) -> DBConversation { + DBConversation( + id: id, + inboxId: inboxId, + clientId: clientId, + clientConversationId: id, + inviteTag: inviteTag, + creatorId: inboxId, + kind: .group, + consent: consent, + createdAt: createdAt, + name: nil, + description: nil, + imageURLString: nil, + publicImageURLString: nil, + includeInfoInPublicPreview: false, + expiresAt: nil, + debugInfo: .empty, + isLocked: false, + imageSalt: nil, + imageNonce: nil, + imageEncryptionKey: nil + ) + } +} + +// MARK: - Pending Invite Cap and Stale Expiry Tests + +@Suite("InboxLifecycleManager Pending Invite Cap Tests", .serialized) +struct InboxLifecycleManagerPendingInviteCapTests { + + @Test("Pending invite inboxes are capped during app launch") + func testPendingInvitesAreCappedOnAppLaunch() async throws { + let fixtures = makeTestFixtures(maxAwake: 10, maxPendingInvites: 2) + let manager = fixtures.manager + + fixtures.activityRepo.activities = [ + InboxActivity(clientId: "pi-1", inboxId: "inbox-pi-1", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "pi-2", inboxId: "inbox-pi-2", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "pi-3", inboxId: "inbox-pi-3", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "pi-4", inboxId: "inbox-pi-4", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "regular-1", inboxId: "inbox-regular-1", lastActivity: Date(), conversationCount: 1), + ] + + fixtures.pendingInviteRepo.pendingInvites = [ + PendingInviteInfo(clientId: "pi-1", inboxId: "inbox-pi-1", pendingConversationIds: ["draft-1"]), + PendingInviteInfo(clientId: "pi-2", inboxId: "inbox-pi-2", pendingConversationIds: ["draft-2"]), + PendingInviteInfo(clientId: "pi-3", inboxId: "inbox-pi-3", pendingConversationIds: ["draft-3"]), + PendingInviteInfo(clientId: "pi-4", inboxId: "inbox-pi-4", pendingConversationIds: ["draft-4"]), + ] + + await manager.initializeOnAppLaunch() + + var awakePendingCount = 0 + var sleepingPendingCount = 0 + for pid in ["pi-1", "pi-2", "pi-3", "pi-4"] { + if await manager.isAwake(clientId: pid) { + awakePendingCount += 1 + } else if await manager.isSleeping(clientId: pid) { + sleepingPendingCount += 1 + } + } + + #expect(awakePendingCount == 2, "Only maxAwakePendingInvites (2) should be awake") + #expect(sleepingPendingCount == 2, "Excess pending invite inboxes should be sleeping") + + #expect(await manager.isAwake(clientId: "regular-1"), "Regular inbox should still wake") + } + + @Test("attemptWake respects pending invite cap") + func testAttemptWakeRespectsPendingInviteCap() async throws { + let fixtures = makeTestFixtures(maxAwake: 5, maxPendingInvites: 1) + let manager = fixtures.manager + + fixtures.activityRepo.activities = [ + InboxActivity(clientId: "pi-1", inboxId: "inbox-pi-1", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "pi-2", inboxId: "inbox-pi-2", lastActivity: nil, conversationCount: 0), + ] + + fixtures.pendingInviteRepo.pendingInvites = [ + PendingInviteInfo(clientId: "pi-1", inboxId: "inbox-pi-1", pendingConversationIds: ["draft-1"]), + PendingInviteInfo(clientId: "pi-2", inboxId: "inbox-pi-2", pendingConversationIds: ["draft-2"]), + ] + + try await manager.wakeAndDiscard(clientId: "pi-1", inboxId: "inbox-pi-1", reason: .pendingInvite) + #expect(await manager.isAwake(clientId: "pi-1")) + + // At cap, but below overall capacity — should still succeed since overall capacity allows it + try await manager.wakeAndDiscard(clientId: "pi-2", inboxId: "inbox-pi-2", reason: .pendingInvite) + #expect(await manager.isAwake(clientId: "pi-2"), "Under overall capacity, should still wake") + } + + @Test("wake evicts LRU for pending invite even when over pending cap") + func testWakeEvictsLRUForPendingInviteOverCap() async throws { + let fixtures = makeTestFixtures(maxAwake: 2, maxPendingInvites: 1) + let manager = fixtures.manager + + fixtures.activityRepo.activities = [ + InboxActivity(clientId: "pi-1", inboxId: "inbox-pi-1", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "regular-1", inboxId: "inbox-r-1", lastActivity: Date(), conversationCount: 1), + InboxActivity(clientId: "pi-2", inboxId: "inbox-pi-2", lastActivity: nil, conversationCount: 0), + ] + + fixtures.pendingInviteRepo.pendingInvites = [ + PendingInviteInfo(clientId: "pi-1", inboxId: "inbox-pi-1", pendingConversationIds: ["draft-1"]), + PendingInviteInfo(clientId: "pi-2", inboxId: "inbox-pi-2", pendingConversationIds: ["draft-2"]), + ] + + try await manager.wakeAndDiscard(clientId: "pi-1", inboxId: "inbox-pi-1", reason: .pendingInvite) + try await manager.wakeAndDiscard(clientId: "regular-1", inboxId: "inbox-r-1", reason: .appLaunch) + + #expect(await manager.awakeClientIds.count == 2) + + // wake() evicts LRU first, so pi-2 can wake even though over pending cap + try await manager.wakeAndDiscard(clientId: "pi-2", inboxId: "inbox-pi-2", reason: .pendingInvite) + #expect(await manager.isAwake(clientId: "pi-2"), "User-initiated wake should succeed via LRU eviction") + #expect(await manager.isSleeping(clientId: "regular-1"), "LRU should be evicted") + } + + @Test("Sleep allows sleeping pending invite inbox when over cap") + func testSleepAllowsPendingInviteOverCap() async throws { + let fixtures = makeTestFixtures(maxAwake: 10, maxPendingInvites: 1) + let manager = fixtures.manager + + fixtures.activityRepo.activities = [ + InboxActivity(clientId: "pi-1", inboxId: "inbox-pi-1", lastActivity: nil, conversationCount: 0), + InboxActivity(clientId: "pi-2", inboxId: "inbox-pi-2", lastActivity: nil, conversationCount: 0), + ] + + fixtures.pendingInviteRepo.pendingInvites = [ + PendingInviteInfo(clientId: "pi-1", inboxId: "inbox-pi-1", pendingConversationIds: ["draft-1"]), + PendingInviteInfo(clientId: "pi-2", inboxId: "inbox-pi-2", pendingConversationIds: ["draft-2"]), + ] + + try await manager.wakeAndDiscard(clientId: "pi-1", inboxId: "inbox-pi-1", reason: .pendingInvite) + try await manager.wakeAndDiscard(clientId: "pi-2", inboxId: "inbox-pi-2", reason: .pendingInvite) + + #expect(await manager.awakeClientIds.count == 2) + + // With 2 awake and cap of 1, sleeping pi-2 should succeed (over cap) + await manager.sleep(clientId: "pi-2") + #expect(await manager.isSleeping(clientId: "pi-2"), "Over-cap pending invite should be sleepable") + + // pi-1 is under the cap, so sleeping it should be prevented + await manager.sleep(clientId: "pi-1") + #expect(await manager.isAwake(clientId: "pi-1"), "Under-cap pending invite should not be slept") + } + + @Test("Pending invite inboxes with no activity record are also capped") + func testPendingInvitesNoActivityRecordAreCapped() async throws { + let fixtures = makeTestFixtures(maxAwake: 10, maxPendingInvites: 1) + let manager = fixtures.manager + + // No activity records — these inboxes only appear in the pending invite list + fixtures.activityRepo.activities = [] + + fixtures.pendingInviteRepo.pendingInvites = [ + PendingInviteInfo(clientId: "pi-1", inboxId: "inbox-pi-1", pendingConversationIds: ["draft-1"]), + PendingInviteInfo(clientId: "pi-2", inboxId: "inbox-pi-2", pendingConversationIds: ["draft-2"]), + PendingInviteInfo(clientId: "pi-3", inboxId: "inbox-pi-3", pendingConversationIds: ["draft-3"]), + ] + + await manager.initializeOnAppLaunch() + + var awakePendingCount = 0 + for pid in ["pi-1", "pi-2", "pi-3"] { + if await manager.isAwake(clientId: pid) { + awakePendingCount += 1 + } + } + + #expect(awakePendingCount == 1, "Only 1 pending invite inbox should be awake (cap = 1)") + } + + // MARK: - Test Helpers + + struct TestFixtures { + let manager: InboxLifecycleManager + let activityRepo: MockInboxActivityRepository + let pendingInviteRepo: MockPendingInviteRepository + let databaseManager: MockDatabaseManager + } + + func makeTestFixtures(maxAwake: Int = 50, maxPendingInvites: Int = 3) -> TestFixtures { + let databaseManager = MockDatabaseManager.makeTestDatabase() + let activityRepo = MockInboxActivityRepository() + let pendingInviteRepo = MockPendingInviteRepository() + + let manager = InboxLifecycleManager( + maxAwakeInboxes: maxAwake, + maxAwakePendingInvites: maxPendingInvites, + databaseReader: databaseManager.dbReader, + databaseWriter: databaseManager.dbWriter, + identityStore: MockKeychainIdentityStore(), + environment: .tests, + platformProviders: .mock, + activityRepository: activityRepo, + pendingInviteRepository: pendingInviteRepo + ) + + return TestFixtures( + manager: manager, + activityRepo: activityRepo, + pendingInviteRepo: pendingInviteRepo, + databaseManager: databaseManager + ) + } +} From 7ed6d32a480013f869ab98e1e45ff28284167b37 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Tue, 24 Feb 2026 21:19:08 -0800 Subject: [PATCH 6/8] Remove duplicate cleanupStalePendingInvites() call --- .../Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift index b4cf48522..374ddeb0a 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift @@ -455,8 +455,6 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { await cleanupStalePendingInvites() - await cleanupStalePendingInvites() - do { let allActivities = try activityRepository.allInboxActivities() let allPendingInvites = try pendingInviteRepository.allPendingInvites() From 53f77084df4254bd05d5f3717756ffcb6824f901 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Tue, 24 Feb 2026 21:20:17 -0800 Subject: [PATCH 7/8] Fix broken string interpolation in log message --- .../Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift index 374ddeb0a..05ed2ba03 100644 --- a/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift +++ b/ConvosCore/Sources/ConvosCore/Inboxes/InboxLifecycleManager.swift @@ -611,7 +611,7 @@ public actor InboxLifecycleManager: InboxLifecycleManagerProtocol { } if !clientIdsToKeep.isEmpty { - Log.debug("Skipped \\((clientIdsToKeep.count\)) inbox(es) with other conversations or multi-member groups") + Log.debug("Skipped \(clientIdsToKeep.count) inbox(es) with other conversations or multi-member groups") } Log.info("Deleted \(deletedCount) expired pending invite(s), cleaned up \(safeToDeleteClientIds.count) inbox(es)") From a74424e757004d64af8f284c7f6a54672898dce4 Mon Sep 17 00:00:00 2001 From: yewreeka Date: Tue, 24 Feb 2026 21:22:49 -0800 Subject: [PATCH 8/8] Remove accidentally committed sqlite files --- qa/cxdb/qa.sqlite | Bin 110592 -> 0 bytes qa/cxdb/qa.sqlite-shm | Bin 32768 -> 0 bytes qa/cxdb/qa.sqlite-wal | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 qa/cxdb/qa.sqlite delete mode 100644 qa/cxdb/qa.sqlite-shm delete mode 100644 qa/cxdb/qa.sqlite-wal diff --git a/qa/cxdb/qa.sqlite b/qa/cxdb/qa.sqlite deleted file mode 100644 index cac8b42196047be60dfa17dc9eec732bd3038260..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 110592 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 diff --git a/qa/cxdb/qa.sqlite-shm b/qa/cxdb/qa.sqlite-shm deleted file mode 100644 index fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeIuAr62r3