mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
Remove noise service exposure; single-owner selection state
Transport callers no longer reach the raw NoiseEncryptionService: getNoiseService() is deleted in favor of narrow purpose-named Transport methods (session public key, identity fingerprint, static/signing keys, sign/verify, callback installation). VerificationService now reaches crypto through the transport, so it can no longer pin a stale service across a panic reset. myPeerID/myNickname become private(set); the existing setNickname mutator is the sole nickname path. ConversationStore is now the sole owner of private-chat selection: PrivateChatManager.selectedPeer is a published read-only mirror, and startChat/endChat mutate through the store intent. The bridge method and its five call sites are deleted, removing a latent bug where a stale manager selection pushed back into the store could resurrect a just-removed conversation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,7 @@ final class AppRuntime: ObservableObject {
|
||||
|
||||
started = true
|
||||
NotificationDelegate.shared.runtime = self
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||
announceInitialTorStatusIfNeeded()
|
||||
|
||||
Task(priority: .utility) { [weak self] in
|
||||
|
||||
@@ -401,10 +401,17 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
// MARK: Identity
|
||||
|
||||
var myPeerID = PeerID(str: "")
|
||||
var myNickname: String = "anon"
|
||||
|
||||
|
||||
/// Derived from the Noise identity fingerprint; rotated only via
|
||||
/// `refreshPeerIdentity()` (e.g. panic reset). Externally read-only —
|
||||
/// no out-of-band mutation may bypass that derivation.
|
||||
private(set) var myPeerID = PeerID(str: "")
|
||||
/// Externally read-only; mutate via `setNickname(_:)`, which also
|
||||
/// broadcasts the change to peers.
|
||||
private(set) var myNickname: String = "anon"
|
||||
|
||||
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
||||
/// an announce so peers learn the new name.
|
||||
func setNickname(_ nickname: String) {
|
||||
self.myNickname = nickname
|
||||
// Send announce to notify peers of nickname change (force send)
|
||||
@@ -573,8 +580,40 @@ final class BLEService: NSObject {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
return noiseService
|
||||
// MARK: Noise identity/session access (narrow Transport wrappers)
|
||||
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
|
||||
noiseService.getPeerPublicKeyData(peerID)
|
||||
}
|
||||
|
||||
func noiseIdentityFingerprint() -> String {
|
||||
noiseService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
func noiseStaticPublicKeyData() -> Data {
|
||||
noiseService.getStaticPublicKeyData()
|
||||
}
|
||||
|
||||
func noiseSigningPublicKeyData() -> Data {
|
||||
noiseService.getSigningPublicKeyData()
|
||||
}
|
||||
|
||||
func noiseSignData(_ data: Data) -> Data? {
|
||||
noiseService.signData(data)
|
||||
}
|
||||
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||||
noiseService.verifySignature(signature, for: data, publicKey: publicKey)
|
||||
}
|
||||
|
||||
func installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
) {
|
||||
// `onPeerAuthenticated` is additive (the encryption service keeps an
|
||||
// array of handlers); `onHandshakeRequired` is a single slot.
|
||||
noiseService.onPeerAuthenticated = onPeerAuthenticated
|
||||
noiseService.onHandshakeRequired = onHandshakeRequired
|
||||
}
|
||||
|
||||
func getCurrentBluetoothState() -> CBManagerState {
|
||||
|
||||
@@ -142,17 +142,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
if let noiseService = Self.cachedNoiseService {
|
||||
return noiseService
|
||||
}
|
||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
Self.cachedNoiseService = noiseService
|
||||
return noiseService
|
||||
}
|
||||
|
||||
// Nostr does not use Noise sessions here; the inert Transport defaults
|
||||
// for the noise* identity hooks apply.
|
||||
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@@ -16,8 +17,16 @@ import SwiftUI
|
||||
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
|
||||
/// `privateChats` / `unreadMessages` properties below are read-only views
|
||||
/// derived from it.
|
||||
@MainActor
|
||||
final class PrivateChatManager: ObservableObject {
|
||||
@Published var selectedPeer: PeerID? = nil
|
||||
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the
|
||||
/// store is the sole owner of conversation selection. Kept `@Published`
|
||||
/// so existing observers (`objectWillChange` forwarding into
|
||||
/// `ChatViewModel`) keep firing on selection changes. Mutate via
|
||||
/// `startChat(with:)` / `endChat()`, which route through the store's
|
||||
/// `setSelectedPrivatePeer` intent.
|
||||
@Published private(set) var selectedPeer: PeerID? = nil
|
||||
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
@@ -27,13 +36,30 @@ final class PrivateChatManager: ObservableObject {
|
||||
weak var messageRouter: MessageRouter?
|
||||
// Peer service for looking up peer info during consolidation
|
||||
weak var unifiedPeerService: UnifiedPeerService?
|
||||
/// Single source of truth for message state; injected by the
|
||||
/// bootstrapper (`wireServiceGraph`).
|
||||
var conversationStore: ConversationStore?
|
||||
/// Single source of truth for message and selection state; injected by
|
||||
/// the bootstrapper (`wireServiceGraph`).
|
||||
var conversationStore: ConversationStore? {
|
||||
didSet { bindSelectionMirror() }
|
||||
}
|
||||
|
||||
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
|
||||
self.meshService = meshService
|
||||
self.conversationStore = conversationStore
|
||||
bindSelectionMirror() // didSet does not fire during init
|
||||
}
|
||||
|
||||
/// Keeps `selectedPeer` in lock-step with the store's selection axis
|
||||
/// (including store-internal handoffs such as conversation migration).
|
||||
private func bindSelectionMirror() {
|
||||
guard let store = conversationStore else {
|
||||
selectedPeerMirrorCancellable = nil
|
||||
return
|
||||
}
|
||||
selectedPeerMirrorCancellable = store.$selectedPrivatePeerID
|
||||
.sink { [weak self] peerID in
|
||||
guard let self, self.selectedPeer != peerID else { return }
|
||||
self.selectedPeer = peerID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Derived message state (read-only compat views)
|
||||
@@ -191,10 +217,14 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a private chat with a peer
|
||||
/// Start a private chat with a peer. Selection is mutated through the
|
||||
/// store's intent (the store owns it); the manager keeps its side
|
||||
/// effects (fingerprint tracking, read receipts, unread clearing).
|
||||
@MainActor
|
||||
func startChat(with peerID: PeerID) {
|
||||
selectedPeer = peerID
|
||||
// Also creates the conversation if needed and updates the derived
|
||||
// `selectedConversationID`; `selectedPeer` mirrors the change.
|
||||
conversationStore?.setSelectedPrivatePeer(peerID)
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
@@ -203,14 +233,12 @@ final class PrivateChatManager: ObservableObject {
|
||||
|
||||
// Mark messages as read
|
||||
markAsRead(from: peerID)
|
||||
|
||||
// Initialize chat if needed
|
||||
conversationStore?.conversation(for: .directPeer(peerID))
|
||||
}
|
||||
|
||||
/// End the current private chat
|
||||
/// End the current private chat (selection returns to the active public
|
||||
/// channel's conversation).
|
||||
func endChat() {
|
||||
selectedPeer = nil
|
||||
conversationStore?.setSelectedPrivatePeer(nil)
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,27 @@ protocol Transport: AnyObject {
|
||||
func getFingerprint(for peerID: PeerID) -> String?
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Noise identity/session access. Narrow, purpose-named wrappers so the
|
||||
// underlying NoiseEncryptionService (and its peer-binding/session
|
||||
// orchestration) is never exposed outside the transport.
|
||||
/// The remote static public key of the Noise session with `peerID`, if established.
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
|
||||
/// Fingerprint of our own Noise static identity key.
|
||||
func noiseIdentityFingerprint() -> String
|
||||
/// Our Noise static public key (Curve25519 key agreement).
|
||||
func noiseStaticPublicKeyData() -> Data
|
||||
/// Our Noise signing public key (Ed25519).
|
||||
func noiseSigningPublicKeyData() -> Data
|
||||
/// Signs `data` with our Noise signing key.
|
||||
func noiseSignData(_ data: Data) -> Data?
|
||||
/// Verifies an Ed25519 `signature` over `data` against `publicKey`.
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool
|
||||
/// Registers session-lifecycle callbacks (peer authenticated / handshake required).
|
||||
func installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
)
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
@@ -85,6 +105,19 @@ protocol Transport: AnyObject {
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise sessions (e.g. NostrTransport).
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||
func noiseIdentityFingerprint() -> String { "" }
|
||||
func noiseStaticPublicKeyData() -> Data { Data() }
|
||||
func noiseSigningPublicKeyData() -> Data { Data() }
|
||||
func noiseSignData(_ data: Data) -> Data? { nil }
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
|
||||
func installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
) {}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
|
||||
@@ -4,9 +4,11 @@ import Foundation
|
||||
final class VerificationService {
|
||||
static let shared = VerificationService()
|
||||
|
||||
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||
private var noise: NoiseEncryptionService?
|
||||
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||
// Injected running transport (do NOT create new BLEService). Noise
|
||||
// identity operations go through the transport's narrow noise* wrappers
|
||||
// so the raw NoiseEncryptionService is never exposed.
|
||||
private var transport: Transport?
|
||||
func configure(with transport: Transport) { self.transport = transport }
|
||||
|
||||
/// Encapsulates the data encoded into a verification QR
|
||||
struct VerificationQR: Codable {
|
||||
@@ -77,16 +79,16 @@ final class VerificationService {
|
||||
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||
return c.value
|
||||
}
|
||||
guard let noise = noise else { return nil }
|
||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||
guard let transport = transport else { return nil }
|
||||
let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
guard let sig = noise.signData(msg) else { return nil }
|
||||
guard let sig = transport.noiseSignData(msg) else { return nil }
|
||||
let signed = VerificationQR(v: payload.v,
|
||||
noiseKeyHex: payload.noiseKeyHex,
|
||||
signKeyHex: payload.signKeyHex,
|
||||
@@ -108,8 +110,8 @@ final class VerificationService {
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let noise = noise else { return nil }
|
||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
guard let transport = transport else { return nil }
|
||||
let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
return ok ? qr : nil
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ final class VerificationService {
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||
guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil }
|
||||
var tlv = Data()
|
||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||
@@ -178,7 +180,7 @@ final class VerificationService {
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||
guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,9 @@ protocol ChatPeerIdentityContext: AnyObject {
|
||||
/// `peerID`'s chat. (Single mutation path into the owner's
|
||||
/// `sentReadReceipts`; this coordinator never touches the raw set.)
|
||||
func syncReadReceiptsForSentMessages(for peerID: PeerID)
|
||||
/// Re-targets the private chat session in the chat manager (no store-sync side effects).
|
||||
/// Re-targets the private chat session: selection mutates through the
|
||||
/// `ConversationStore` intent (the store owns selection).
|
||||
func beginPrivateChatSession(with peerID: PeerID)
|
||||
func synchronizeConversationSelectionStore()
|
||||
func markPrivateMessagesAsRead(from peerID: PeerID)
|
||||
|
||||
// MARK: Unified peer service
|
||||
@@ -154,15 +154,19 @@ extension ChatViewModel: ChatPeerIdentityContext {
|
||||
}
|
||||
|
||||
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
|
||||
meshService.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
if case .established = meshService.getNoiseSessionState(for: peerID) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNoiseSession(with peerID: PeerID) -> Bool {
|
||||
meshService.getNoiseService().hasSession(with: peerID)
|
||||
switch meshService.getNoiseSessionState(for: peerID) {
|
||||
case .established, .handshaking: return true
|
||||
case .none, .handshakeQueued, .failed: return false
|
||||
}
|
||||
}
|
||||
|
||||
func noiseIdentityFingerprint() -> String {
|
||||
meshService.getNoiseService().getIdentityFingerprint()
|
||||
meshService.noiseIdentityFingerprint()
|
||||
}
|
||||
|
||||
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
|
||||
@@ -372,7 +376,6 @@ final class ChatPeerIdentityCoordinator {
|
||||
context.selectedPrivateChatFingerprint = nil
|
||||
}
|
||||
context.beginPrivateChatSession(with: peerID)
|
||||
context.synchronizeConversationSelectionStore()
|
||||
context.markPrivateMessagesAsRead(from: peerID)
|
||||
}
|
||||
|
||||
@@ -564,7 +567,11 @@ private extension ChatPeerIdentityCoordinator {
|
||||
|
||||
@MainActor
|
||||
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
|
||||
if context.selectedPrivateChatPeer == oldPeerID {
|
||||
// Capture before the migration: the store hands its selection off to
|
||||
// `newPeerID` during `migrateChatState`, and the manager's selection
|
||||
// mirrors the store, so the old peer ID is no longer selected after.
|
||||
let wasSelected = context.selectedPrivateChatPeer == oldPeerID
|
||||
if wasSelected {
|
||||
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
|
||||
} else if !context.privateMessages(for: oldPeerID).isEmpty {
|
||||
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
||||
@@ -572,7 +579,7 @@ private extension ChatPeerIdentityCoordinator {
|
||||
|
||||
migrateChatState(from: oldPeerID, to: newPeerID)
|
||||
|
||||
if context.selectedPrivateChatPeer == oldPeerID {
|
||||
if wasSelected {
|
||||
context.selectedPrivateChatPeer = newPeerID
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
}
|
||||
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
|
||||
meshService.getNoiseService().getPeerPublicKeyData(peerID)
|
||||
meshService.noiseSessionPublicKeyData(for: peerID)
|
||||
}
|
||||
|
||||
func flushRouterOutbox(for peerID: PeerID) {
|
||||
|
||||
@@ -98,13 +98,14 @@ extension ChatViewModel: ChatVerificationContext {
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
) {
|
||||
let noiseService = meshService.getNoiseService()
|
||||
noiseService.onPeerAuthenticated = onPeerAuthenticated
|
||||
noiseService.onHandshakeRequired = onHandshakeRequired
|
||||
meshService.installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: onPeerAuthenticated,
|
||||
onHandshakeRequired: onHandshakeRequired
|
||||
)
|
||||
}
|
||||
|
||||
func noiseStaticPublicKeyData() -> Data {
|
||||
meshService.getNoiseService().getStaticPublicKeyData()
|
||||
meshService.noiseStaticPublicKeyData()
|
||||
}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||
|
||||
@@ -204,7 +204,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
} else {
|
||||
privateChatManager.endChat()
|
||||
}
|
||||
synchronizeConversationSelectionStore()
|
||||
}
|
||||
}
|
||||
/// Read-only derived view of the store's unread direct conversations.
|
||||
@@ -243,7 +242,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped }
|
||||
// Fallback: derive from active Noise session if available
|
||||
if shortPeerID.id.count == 16,
|
||||
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
|
||||
let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) {
|
||||
let stable = PeerID(hexData: key)
|
||||
peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID)
|
||||
return stable
|
||||
@@ -536,10 +535,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
/// Moves the open private chat to `newPeerID` when the current selection is
|
||||
/// one of the peer IDs being migrated away (side-effectful: re-targets the
|
||||
/// private chat session and resyncs the conversation stores).
|
||||
/// private chat session — fingerprint refresh, read receipts).
|
||||
///
|
||||
/// Note: when this runs after a store `migrateConversation`, the store has
|
||||
/// already handed the selection itself off to `newPeerID` (and the manager
|
||||
/// mirrors it), so a selection that reads `newPeerID` is also re-targeted
|
||||
/// to run the session side effects. Selections on unrelated peers are
|
||||
/// untouched.
|
||||
@MainActor
|
||||
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
|
||||
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 }) else { return }
|
||||
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 })
|
||||
|| selectedPrivateChatPeer == newPeerID else { return }
|
||||
selectedPrivateChatPeer = newPeerID
|
||||
}
|
||||
|
||||
@@ -803,7 +809,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
.store(in: &cancellables)
|
||||
|
||||
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||
synchronizeConversationSelectionStore()
|
||||
}
|
||||
|
||||
// MARK: - Deinitialization
|
||||
@@ -1140,8 +1145,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
||||
}
|
||||
|
||||
synchronizeConversationSelectionStore()
|
||||
|
||||
// No need to force UserDefaults synchronization
|
||||
|
||||
// Reinitialize Nostr with new identity
|
||||
@@ -1262,13 +1265,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
// MARK: - Message Handling
|
||||
|
||||
/// Pushes the private-chat manager's selection into the store, which
|
||||
/// derives `selectedConversationID` from it and the active channel.
|
||||
@MainActor
|
||||
func synchronizeConversationSelectionStore() {
|
||||
conversations.setSelectedPrivatePeer(privateChatManager.selectedPeer)
|
||||
}
|
||||
|
||||
/// Invalidates the derived `messages` cache and notifies observers.
|
||||
/// (Formerly pulled the channel's timeline into a stored `messages`
|
||||
/// array; `messages` is now derived from the `ConversationStore`, so
|
||||
|
||||
@@ -108,17 +108,9 @@ private extension ChatViewModelBootstrapper {
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
// Private message state flows through the single-writer
|
||||
// `ConversationStore` intents and its `changes` subject; only the
|
||||
// selection still originates in `PrivateChatManager`.
|
||||
viewModel.privateChatManager.$selectedPeer
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] _ in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.synchronizeConversationSelectionStore()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
// `ConversationStore` intents and its `changes` subject; selection
|
||||
// is owned by the store too (`PrivateChatManager.selectedPeer` is a
|
||||
// read-only mirror), so no selection bridge is needed here.
|
||||
viewModel.participantTracker.objectWillChange
|
||||
.sink { [weak viewModel] _ in
|
||||
viewModel?.objectWillChange.send()
|
||||
@@ -192,8 +184,6 @@ private extension ChatViewModelBootstrapper {
|
||||
if viewModel.hasTrackedPrivateChatSelection {
|
||||
viewModel.updatePrivateChatPeerIfNeeded()
|
||||
}
|
||||
|
||||
viewModel.synchronizeConversationSelectionStore()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
@@ -66,7 +66,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
||||
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
|
||||
private(set) var syncedReadReceiptPeers: [PeerID] = []
|
||||
private(set) var begunChatSessions: [PeerID] = []
|
||||
private(set) var selectionStoreSyncCount = 0
|
||||
private(set) var markedReadPeers: [PeerID] = []
|
||||
|
||||
@discardableResult
|
||||
@@ -83,7 +82,6 @@ private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
|
||||
begunChatSessions.append(peerID)
|
||||
}
|
||||
|
||||
func synchronizeConversationSelectionStore() { selectionStoreSyncCount += 1 }
|
||||
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
|
||||
|
||||
// Unified peer service
|
||||
@@ -283,7 +281,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
|
||||
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
|
||||
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
|
||||
#expect(context.begunChatSessions == [peerID])
|
||||
#expect(context.selectionStoreSyncCount == 1)
|
||||
#expect(context.markedReadPeers == [peerID])
|
||||
|
||||
// Established session: no second handshake.
|
||||
|
||||
@@ -34,9 +34,7 @@ final class MockBLEService: NSObject {
|
||||
weak var delegate: BitchatDelegate?
|
||||
var myPeerID = PeerID(str: "MOCK1234")
|
||||
var myNickname: String = "MockUser"
|
||||
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
|
||||
// Test-specific properties
|
||||
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
|
||||
var sentPackets: [BitchatPacket] = []
|
||||
@@ -240,10 +238,6 @@ final class MockBLEService: NSObject {
|
||||
delegate?.didUpdatePeerList([])
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
return NoiseEncryptionService(keychain: mockKeychain)
|
||||
}
|
||||
|
||||
func getFingerprint(for peerID: String) -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -109,8 +109,34 @@ final class MockTransport: Transport {
|
||||
triggeredHandshakes.append(peerID)
|
||||
}
|
||||
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
NoiseEncryptionService(keychain: mockKeychain)
|
||||
// Noise identity wrappers backed by a mock-keychain encryption service
|
||||
// (mirrors the previous `getNoiseService()` placeholder behavior: a real
|
||||
// identity, but no peer sessions). Exposed so tests can assert against
|
||||
// the same identity the wrappers use.
|
||||
private(set) lazy var mockNoiseService = NoiseEncryptionService(keychain: mockKeychain)
|
||||
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
|
||||
mockNoiseService.getPeerPublicKeyData(peerID)
|
||||
}
|
||||
|
||||
func noiseIdentityFingerprint() -> String {
|
||||
mockNoiseService.getIdentityFingerprint()
|
||||
}
|
||||
|
||||
func noiseStaticPublicKeyData() -> Data {
|
||||
mockNoiseService.getStaticPublicKeyData()
|
||||
}
|
||||
|
||||
func noiseSigningPublicKeyData() -> Data {
|
||||
mockNoiseService.getSigningPublicKeyData()
|
||||
}
|
||||
|
||||
func noiseSignData(_ data: Data) -> Data? {
|
||||
mockNoiseService.signData(data)
|
||||
}
|
||||
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||||
mockNoiseService.verifySignature(signature, for: data, publicKey: publicKey)
|
||||
}
|
||||
|
||||
// MARK: - Messaging
|
||||
|
||||
@@ -21,7 +21,6 @@ private final class DefaultTransportProbe: Transport {
|
||||
let subject = CurrentValueSubject<[TransportPeerSnapshot], Never>([])
|
||||
let myPeerID = PeerID(str: "0011223344556677")
|
||||
var myNickname = "Tester"
|
||||
private let keychain = MockKeychain()
|
||||
private(set) var sentMessages: [(content: String, mentions: [String])] = []
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
@@ -40,7 +39,6 @@ private final class DefaultTransportProbe: Transport {
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) {}
|
||||
func getNoiseService() -> NoiseEncryptionService { NoiseEncryptionService(keychain: keychain) }
|
||||
func sendMessage(_ content: String, mentions: [String]) { sentMessages.append((content, mentions)) }
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {}
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||
|
||||
@@ -98,10 +98,13 @@ final class VerificationServiceTests: XCTestCase {
|
||||
}
|
||||
|
||||
private func makeService() -> (VerificationService, NoiseEncryptionService) {
|
||||
let noise = NoiseEncryptionService(keychain: MockKeychain())
|
||||
// The service consumes Noise identity operations through the
|
||||
// Transport's narrow noise* wrappers; the mock transport's backing
|
||||
// encryption service is returned for direct assertions.
|
||||
let transport = MockTransport()
|
||||
let service = VerificationService()
|
||||
service.configure(with: noise)
|
||||
return (service, noise)
|
||||
service.configure(with: transport)
|
||||
return (service, transport.mockNoiseService)
|
||||
}
|
||||
|
||||
private func makeSignedQR(
|
||||
|
||||
Reference in New Issue
Block a user