mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 01:05:20 +00:00
* Isolate tests from the developer's real login keychain Every test run prompted for the login keychain password (repeatedly, since the xctest runner's code signature changes each build, so "Always Allow" can never stick) and silently deleted the developer's real Nostr identity via panic-mode tests. Two holes let tests reach the real keychain: - NostrIdentityBridge() defaulted to the real KeychainManager. Tests inject mocks, but app-side constructions with no injection point (LocationNotesManager's static bridge, GeohashPresenceService, BoardManager, AppRuntime) read the real chat.bitchat.nostr item when exercised under test. - clearAllAssociations() used raw SecItem* calls that bypassed the injected keychain entirely, so panicClearAllData tests wiped the real Nostr identity items on every run. Fix: centralize FavoritesPersistenceService's test-guarded in-memory default as KeychainManager.makeDefault() and use it for all default keychain parameters, and add deleteAll(service:) to KeychainManagerProtocol so clearAllAssociations() goes through the injected keychain like every other operation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Share one in-memory test keychain per process (Codex P2) A fresh store per makeDefault() call diverges from production, where separate default-constructed bridges share chat.bitchat.nostr — BoardManager's publish and NIP-09 delete paths would derive different geohash identities under test. PreviewKeychainManager gains a lock since the shared instance is reached from arbitrary threads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
319 lines
12 KiB
Swift
319 lines
12 KiB
Swift
import BitLogger
|
|
import BitFoundation
|
|
import Foundation
|
|
import Combine
|
|
|
|
/// Manages persistent favorite relationships between peers
|
|
@MainActor
|
|
final class FavoritesPersistenceService: ObservableObject {
|
|
|
|
struct FavoriteRelationship: Codable {
|
|
let peerNoisePublicKey: Data
|
|
let peerNostrPublicKey: String?
|
|
let peerNickname: String
|
|
let isFavorite: Bool
|
|
let theyFavoritedUs: Bool
|
|
let favoritedAt: Date
|
|
let lastUpdated: Date
|
|
// Track what we last sent as OUR npub to this peer, to avoid resending unless it changes
|
|
// Note: we do not track which npub we last sent to them; sending happens only on favorite toggle
|
|
|
|
var isMutual: Bool {
|
|
isFavorite && theyFavoritedUs
|
|
}
|
|
}
|
|
|
|
// We intentionally do not track when we last sent our npub; sending happens only on favorite toggle.
|
|
|
|
private static let storageKey = "chat.bitchat.favorites"
|
|
private static let keychainService = "chat.bitchat.favorites"
|
|
private let keychain: KeychainManagerProtocol
|
|
|
|
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
|
@Published private(set) var mutualFavorites: Set<Data> = []
|
|
|
|
static let shared = FavoritesPersistenceService()
|
|
|
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
|
self.keychain = keychain
|
|
loadFavorites()
|
|
|
|
// Update mutual favorites when favorites change
|
|
$favorites
|
|
.map { favorites in
|
|
Set(favorites.compactMap { $0.value.isMutual ? $0.key : nil })
|
|
}
|
|
.assign(to: &$mutualFavorites)
|
|
}
|
|
|
|
/// Add or update a favorite
|
|
func addFavorite(
|
|
peerNoisePublicKey: Data,
|
|
peerNostrPublicKey: String? = nil,
|
|
peerNickname: String
|
|
) {
|
|
SecureLogger.info("⭐️ Adding favorite: \(peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
|
|
|
let existing = favorites[peerNoisePublicKey]
|
|
|
|
let relationship = FavoriteRelationship(
|
|
peerNoisePublicKey: peerNoisePublicKey,
|
|
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
|
|
peerNickname: peerNickname,
|
|
isFavorite: true,
|
|
theyFavoritedUs: existing?.theyFavoritedUs ?? false,
|
|
favoritedAt: existing?.favoritedAt ?? Date(),
|
|
lastUpdated: Date()
|
|
)
|
|
|
|
// Log if this creates a mutual favorite
|
|
if relationship.isMutual {
|
|
SecureLogger.info("💕 Mutual favorite relationship established with \(peerNickname)!", category: .session)
|
|
}
|
|
|
|
favorites[peerNoisePublicKey] = relationship
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Remove a favorite
|
|
func removeFavorite(peerNoisePublicKey: Data) {
|
|
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
|
|
SecureLogger.info("⭐️ Removing favorite: \(existing.peerNickname) (\(peerNoisePublicKey.hexEncodedString()))", category: .session)
|
|
|
|
// If they still favorite us, keep the record but mark us as not favoriting
|
|
if existing.theyFavoritedUs {
|
|
let updated = FavoriteRelationship(
|
|
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
peerNickname: existing.peerNickname,
|
|
isFavorite: false,
|
|
theyFavoritedUs: true,
|
|
favoritedAt: existing.favoritedAt,
|
|
lastUpdated: Date()
|
|
)
|
|
favorites[peerNoisePublicKey] = updated
|
|
// Keeping record - they still favorite us
|
|
} else {
|
|
// Neither side favorites, remove completely
|
|
favorites.removeValue(forKey: peerNoisePublicKey)
|
|
// Completely removed from favorites
|
|
}
|
|
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Update when we learn a peer favorited/unfavorited us
|
|
func updatePeerFavoritedUs(
|
|
peerNoisePublicKey: Data,
|
|
favorited: Bool,
|
|
peerNickname: String? = nil,
|
|
peerNostrPublicKey: String? = nil
|
|
) {
|
|
let existing = favorites[peerNoisePublicKey]
|
|
// Callers that can't resolve the live nickname pass the "Unknown"
|
|
// placeholder (e.g. a notification arriving before the announce);
|
|
// never let it clobber a real stored nickname.
|
|
let incoming = peerNickname.flatMap { name in
|
|
(name.isEmpty || name == "Unknown") ? nil : name
|
|
}
|
|
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
|
|
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
|
|
|
let relationship = FavoriteRelationship(
|
|
peerNoisePublicKey: peerNoisePublicKey,
|
|
peerNostrPublicKey: peerNostrPublicKey ?? existing?.peerNostrPublicKey,
|
|
peerNickname: displayName,
|
|
isFavorite: existing?.isFavorite ?? false,
|
|
theyFavoritedUs: favorited,
|
|
favoritedAt: existing?.favoritedAt ?? Date(),
|
|
lastUpdated: Date()
|
|
)
|
|
|
|
if !relationship.isFavorite && !relationship.theyFavoritedUs {
|
|
// Neither side favorites, remove completely
|
|
favorites.removeValue(forKey: peerNoisePublicKey)
|
|
// Removed - neither side favorites anymore
|
|
} else {
|
|
favorites[peerNoisePublicKey] = relationship
|
|
|
|
// Check if this creates a mutual favorite
|
|
if relationship.isMutual {
|
|
SecureLogger.info("💕 Mutual favorite relationship established with \(displayName)!", category: .session)
|
|
}
|
|
}
|
|
|
|
saveFavorites()
|
|
|
|
// Notify observers
|
|
NotificationCenter.default.post(
|
|
name: .favoriteStatusChanged,
|
|
object: nil,
|
|
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
)
|
|
}
|
|
|
|
/// Check if a peer is favorited by us
|
|
func isFavorite(_ peerNoisePublicKey: Data) -> Bool {
|
|
favorites[peerNoisePublicKey]?.isFavorite ?? false
|
|
}
|
|
|
|
/// Check if we have a mutual favorite relationship
|
|
func isMutualFavorite(_ peerNoisePublicKey: Data) -> Bool {
|
|
favorites[peerNoisePublicKey]?.isMutual ?? false
|
|
}
|
|
|
|
/// Get favorite status for a peer
|
|
func getFavoriteStatus(for peerNoisePublicKey: Data) -> FavoriteRelationship? {
|
|
favorites[peerNoisePublicKey]
|
|
}
|
|
|
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
|
guard peerID.isShort else { return nil }
|
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
|
return rel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Clear all favorites - used for panic mode
|
|
func clearAllFavorites() {
|
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
|
|
|
favorites.removeAll()
|
|
saveFavorites()
|
|
|
|
// Delete from keychain directly
|
|
keychain.delete(
|
|
key: Self.storageKey,
|
|
service: Self.keychainService
|
|
)
|
|
|
|
// Post notification for UI update
|
|
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
|
|
}
|
|
|
|
// MARK: - Persistence
|
|
|
|
private func saveFavorites() {
|
|
let relationships = Array(favorites.values)
|
|
// Saving favorite relationships to keychain
|
|
|
|
do {
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(relationships)
|
|
|
|
// Store in keychain for security
|
|
keychain.save(
|
|
key: Self.storageKey,
|
|
data: data,
|
|
service: Self.keychainService,
|
|
accessible: nil
|
|
)
|
|
|
|
// Successfully saved favorites
|
|
} catch {
|
|
SecureLogger.error("Failed to save favorites: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
private func loadFavorites() {
|
|
// Loading favorites from keychain
|
|
|
|
guard let data = keychain.load(
|
|
key: Self.storageKey,
|
|
service: Self.keychainService
|
|
) else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let decoder = JSONDecoder()
|
|
let relationships = try decoder.decode([FavoriteRelationship].self, from: data)
|
|
|
|
SecureLogger.info("✅ Loaded \(relationships.count) favorite relationships", category: .session)
|
|
|
|
// Log Nostr public key info
|
|
for relationship in relationships {
|
|
if relationship.peerNostrPublicKey == nil {
|
|
SecureLogger.warning("⚠️ No Nostr public key stored for '\(relationship.peerNickname)'", category: .session)
|
|
}
|
|
}
|
|
|
|
// Convert to dictionary, cleaning up duplicates by public key (not nickname)
|
|
var seenPublicKeys: [Data: FavoriteRelationship] = [:]
|
|
var cleanedRelationships: [FavoriteRelationship] = []
|
|
|
|
for relationship in relationships {
|
|
// Check for duplicates by public key (the actual unique identifier)
|
|
if let existing = seenPublicKeys[relationship.peerNoisePublicKey] {
|
|
SecureLogger.warning("⚠️ Duplicate favorite found for public key \(relationship.peerNoisePublicKey.hexEncodedString()) - nicknames: '\(existing.peerNickname)' vs '\(relationship.peerNickname)'", category: .session)
|
|
|
|
// Keep the most recent or most complete relationship
|
|
if relationship.lastUpdated > existing.lastUpdated ||
|
|
(relationship.peerNostrPublicKey != nil && existing.peerNostrPublicKey == nil) {
|
|
// Replace with newer/more complete entry
|
|
seenPublicKeys[relationship.peerNoisePublicKey] = relationship
|
|
cleanedRelationships.removeAll { $0.peerNoisePublicKey == relationship.peerNoisePublicKey }
|
|
cleanedRelationships.append(relationship)
|
|
}
|
|
} else {
|
|
seenPublicKeys[relationship.peerNoisePublicKey] = relationship
|
|
cleanedRelationships.append(relationship)
|
|
}
|
|
}
|
|
|
|
// If we cleaned up duplicates, save the cleaned list
|
|
if cleanedRelationships.count < relationships.count {
|
|
// Cleaned up duplicates
|
|
|
|
// Clear and rebuild favorites dictionary
|
|
favorites.removeAll()
|
|
for relationship in cleanedRelationships {
|
|
favorites[relationship.peerNoisePublicKey] = relationship
|
|
}
|
|
|
|
// Save cleaned favorites
|
|
saveFavorites()
|
|
|
|
// Notify that favorites have been cleaned up (synchronously since we're already on main actor)
|
|
NotificationCenter.default.post(name: .favoriteStatusChanged, object: nil)
|
|
} else {
|
|
// No duplicates, just populate normally
|
|
for relationship in cleanedRelationships {
|
|
favorites[relationship.peerNoisePublicKey] = relationship
|
|
}
|
|
}
|
|
|
|
// Log loaded relationships
|
|
// Loaded relationships successfully
|
|
} catch {
|
|
SecureLogger.error("Failed to load favorites: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Names
|
|
|
|
extension Notification.Name {
|
|
static let favoriteStatusChanged = Notification.Name("FavoriteStatusChanged")
|
|
}
|