mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-25 07:41:41 +00:00
Merge origin/main (resolve xcstrings + architecture tests overlap)
This commit is contained in:
commit
46391814e4
8
.github/workflows/periphery.yml
vendored
8
.github/workflows/periphery.yml
vendored
@ -11,9 +11,11 @@ jobs:
|
||||
name: Periphery scan
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||
continue-on-error: true
|
||||
# Blocking, like SwiftLint since #1646. The baseline (committed July 8)
|
||||
# has been stable across a month of merges — the "drop continue-on-error
|
||||
# once the baseline proves stable" condition this job shipped with is met.
|
||||
# Known macOS-scan false positives stay suppressed by the baseline; only
|
||||
# NEW dead code fails the job (--strict below).
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
13
.github/workflows/swift-tests.yml
vendored
13
.github/workflows/swift-tests.yml
vendored
@ -264,11 +264,13 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
# Blocking since the backlog reached zero (all violations fixed; rules the
|
||||
# codebase deliberately breaks are disabled in .swiftlint.yml). --strict
|
||||
# promotes warnings to errors so new violations fail the job. Runs in a
|
||||
# pinned container (no Xcode plugin, no pbxproj changes) so it can never
|
||||
# break the documented xcodebuild path.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
name: SwiftLint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
@ -280,10 +282,9 @@ jobs:
|
||||
# Tag for readability, digest for immutability (tags can be repointed).
|
||||
# Bump both together, deliberately — never a floating tag.
|
||||
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
run: swiftlint lint --strict --reporter github-actions-logging
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .device-lab
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
@ -21,6 +22,7 @@ disabled_rules:
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
- todo # TODOs that cite a tracked issue are deliberate markers, not lint debt
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
|
||||
@ -424,16 +424,24 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
if appChromeModel.isLocationChannelsSheetPresented {
|
||||
let isLocationChannelActive: Bool = {
|
||||
if case .location = chatViewModel.activeChannel { return true }
|
||||
return false
|
||||
}()
|
||||
|
||||
switch Self.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented,
|
||||
isAppInfoPresented: appChromeModel.isAppInfoPresented,
|
||||
hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil,
|
||||
isLocationChannelActive: isLocationChannelActive
|
||||
) {
|
||||
case .warnLocally:
|
||||
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||
return
|
||||
case .ignore:
|
||||
break
|
||||
case .forwardToChat:
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
if appChromeModel.isAppInfoPresented {
|
||||
return
|
||||
}
|
||||
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
func openExternalURL(_ url: URL) {
|
||||
@ -450,3 +458,40 @@ private extension AppRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Screenshot routing
|
||||
|
||||
extension AppRuntime {
|
||||
/// What a screenshot triggers. Nothing on this table sends anything to
|
||||
/// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`).
|
||||
enum ScreenshotCaptureResponse: Equatable {
|
||||
/// Show the local location-privacy alert; nothing is sent anywhere.
|
||||
case warnLocally
|
||||
/// Do nothing. App Info holds no conversation or location content.
|
||||
case ignore
|
||||
/// Hand to the chat layer: a DM notice goes to the peer when a
|
||||
/// secure session exists; public timelines stay silent. Mesh
|
||||
/// deliberately gets no local alert either — a mesh screenshot
|
||||
/// reveals no place and triggers no send, so there is nothing to
|
||||
/// warn about, and alerting on every screenshot would train people
|
||||
/// to dismiss the one alert that matters (the location one).
|
||||
case forwardToChat
|
||||
}
|
||||
|
||||
/// Pure decision table so the screenshot routing is testable without a
|
||||
/// runtime.
|
||||
nonisolated static func resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: Bool,
|
||||
isAppInfoPresented: Bool,
|
||||
hasPrivateChatOpen: Bool,
|
||||
isLocationChannelActive: Bool
|
||||
) -> ScreenshotCaptureResponse {
|
||||
if isLocationChannelsSheetPresented { return .warnLocally }
|
||||
if isAppInfoPresented { return .ignore }
|
||||
// A geohash timeline screenshot still reveals a place — warn the
|
||||
// person taking it, locally, with the same alert the channel sheet
|
||||
// uses.
|
||||
if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally }
|
||||
return .forwardToChat
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ import UIKit
|
||||
final class ConversationUIModel: ObservableObject {
|
||||
@Published private(set) var showAutocomplete = false
|
||||
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||
@Published private(set) var selectedAutocompleteIndex = 0
|
||||
@Published private(set) var currentNickname: String
|
||||
@Published private(set) var isBatchingPublic = false
|
||||
@Published private(set) var canSendMediaInCurrentContext = true
|
||||
@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject {
|
||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||
self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex
|
||||
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||
|
||||
bind()
|
||||
@ -104,6 +106,31 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.completeNickname(nickname, in: &text)
|
||||
}
|
||||
|
||||
/// Accept the currently highlighted mention suggestion, if any.
|
||||
func completeSelectedSuggestion(in text: inout String) -> Bool {
|
||||
guard showAutocomplete,
|
||||
autocompleteSuggestions.indices.contains(selectedAutocompleteIndex)
|
||||
else { return false }
|
||||
_ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Dismiss the mention suggestion panel without inserting (Escape).
|
||||
func dismissAutocomplete() {
|
||||
guard showAutocomplete else { return }
|
||||
chatViewModel.showAutocomplete = false
|
||||
chatViewModel.autocompleteSuggestions = []
|
||||
chatViewModel.autocompleteRange = nil
|
||||
chatViewModel.selectedAutocompleteIndex = 0
|
||||
}
|
||||
|
||||
func moveAutocompleteSelection(by delta: Int) {
|
||||
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
|
||||
let count = min(4, autocompleteSuggestions.count)
|
||||
let next = (selectedAutocompleteIndex + delta + count) % count
|
||||
chatViewModel.selectedAutocompleteIndex = next
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
@ -128,6 +155,18 @@ final class ConversationUIModel: ObservableObject {
|
||||
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||
}
|
||||
|
||||
/// Whether a private-message row should show the filled verification seal
|
||||
/// next to the sender name (#1439). Scoped to DMs only — public timelines
|
||||
/// have different trust semantics and stay undressed.
|
||||
func showsVerifiedSeal(for message: BitchatMessage) -> Bool {
|
||||
guard message.isPrivate,
|
||||
message.sender != "system",
|
||||
!isSentByCurrentUser(message),
|
||||
let peerID = message.senderPeerID else { return false }
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return chatViewModel.peerIdentityStore.isVerified(fingerprint)
|
||||
}
|
||||
|
||||
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||
if peerID.isGeoDM || peerID.isGeoChat {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
@ -193,6 +232,10 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$autocompleteSuggestions)
|
||||
|
||||
chatViewModel.$selectedAutocompleteIndex
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$selectedAutocompleteIndex)
|
||||
|
||||
chatViewModel.$isBatchingPublic
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
@ -219,6 +262,15 @@ final class ConversationUIModel: ObservableObject {
|
||||
self?.refreshComputedState()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Verify/unverify while a DM is open must repaint existing rows —
|
||||
// showsVerifiedSeal is computed per render, so forward the store change.
|
||||
chatViewModel.peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func refreshComputedState() {
|
||||
|
||||
@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable {
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
/// A direct conversation whose person is NOT in any roster above it. Without
|
||||
/// this section, a DM from a passerby became unreachable the moment they went
|
||||
/// offline: the roster filters to connected/reachable/mutual-favorite, the
|
||||
/// header envelope only exists while unread, and /msg can't resolve offline
|
||||
/// strangers — the thread was still in memory with no row anywhere in the UI.
|
||||
struct RecentChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let displayName: String
|
||||
let hasUnread: Bool
|
||||
let lastActivity: Date
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@Published private(set) var recentChatRows: [RecentChatRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Direct-conversation changes reorder or create recent-chat rows.
|
||||
// Filtered to `.direct` so public-timeline traffic (every mesh or
|
||||
// geohash message emits a change) doesn't rebuild the sheet.
|
||||
conversations.changes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] change in
|
||||
guard case .direct = Self.changedConversationID(change) else { return }
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject {
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject {
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
self.recentChatRows = recentChatRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
@ -255,10 +283,101 @@ final class PeerListModel: ObservableObject {
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
} +
|
||||
recentChatRows.map {
|
||||
"chat:\($0.id)-\($0.displayName)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
/// Direct conversations with people absent from the rosters above:
|
||||
/// the offline passerby DM, the geoDM from a channel since left. Rows
|
||||
/// mirrored under both an ephemeral and a stable peer ID collapse to
|
||||
/// one row per identity (newest activity wins), matching how the
|
||||
/// private-chat coordinators consolidate on open.
|
||||
private func buildRecentChatRows(
|
||||
meshRows: [MeshPeerRow],
|
||||
geohashPeople: [GeohashPersonRow]
|
||||
) -> [RecentChatRow] {
|
||||
// A conversation can be keyed by the stable Noise peer ID while the
|
||||
// roster lists the ephemeral one — compare fingerprints too.
|
||||
var visibleIdentities = Set<String>()
|
||||
for row in meshRows {
|
||||
visibleIdentities.insert(row.peerID.id)
|
||||
if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) {
|
||||
visibleIdentities.insert(fingerprint)
|
||||
}
|
||||
}
|
||||
// Someone visible in the geohash section must not also get a chat
|
||||
// row: their GeoDM conversation is keyed by the nostr_ form of the
|
||||
// same pubkey the roster lists.
|
||||
for person in geohashPeople {
|
||||
visibleIdentities.insert(PeerID(nostr_: person.id).id)
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
let peerID: PeerID
|
||||
let lastActivity: Date
|
||||
}
|
||||
var bestByIdentity: [String: Candidate] = [:]
|
||||
|
||||
for (id, conversation) in conversations.conversationsByID {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
let peerID = handle.routingPeerID
|
||||
// Groups have their own section; blocked people get no row.
|
||||
guard !peerID.isGroup else { continue }
|
||||
guard let lastMessage = conversation.messages.last else { continue }
|
||||
guard !chatViewModel.isPeerBlocked(peerID) else { continue }
|
||||
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peerID)
|
||||
if visibleIdentities.contains(peerID.id) { continue }
|
||||
if let fingerprint, visibleIdentities.contains(fingerprint) { continue }
|
||||
|
||||
let identityKey = fingerprint ?? peerID.id
|
||||
if let existing = bestByIdentity[identityKey],
|
||||
existing.lastActivity >= lastMessage.timestamp {
|
||||
continue
|
||||
}
|
||||
bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp)
|
||||
}
|
||||
|
||||
return bestByIdentity.values
|
||||
.sorted { $0.lastActivity > $1.lastActivity }
|
||||
.map { candidate in
|
||||
RecentChatRow(
|
||||
peerID: candidate.peerID,
|
||||
displayName: displayName(for: candidate.peerID),
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID),
|
||||
lastActivity: candidate.lastActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// GeoDM conversations resolve through the Nostr mapping —
|
||||
/// `resolveNickname` only consults mesh identity sources and would
|
||||
/// render a `nostr_…` key as an opaque `anonnost` fallback.
|
||||
private func displayName(for peerID: PeerID) -> String {
|
||||
if peerID.isGeoDM {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
}
|
||||
return chatViewModel.resolveNickname(for: peerID)
|
||||
}
|
||||
|
||||
private static func changedConversationID(_ change: ConversationChange) -> ConversationID {
|
||||
switch change {
|
||||
case .appended(let id, _),
|
||||
.updated(let id, _),
|
||||
.statusChanged(let id, _, _),
|
||||
.messageRemoved(let id, _),
|
||||
.cleared(let id),
|
||||
.removed(let id),
|
||||
.unreadChanged(let id, _):
|
||||
return id
|
||||
case .migrated(_, let to):
|
||||
return to
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
|
||||
@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
favoriteStatus.isMutual,
|
||||
// No stored recipient key means no Nostr delivery — and the
|
||||
// "end-to-end encrypted" caption keys off .nostrAvailable.
|
||||
favoriteStatus.peerNostrPublicKey != nil {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,13 +33,22 @@ struct BitchatPeer: Equatable {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
} else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil {
|
||||
// Mutual favorites can communicate via Nostr when offline — but
|
||||
// only with a stored recipient key. NostrTransport applies the
|
||||
// same rule when computing reachable peers; without a key,
|
||||
// "available" would be a lie, and the DM header's
|
||||
// "end-to-end encrypted" caption keys off this state.
|
||||
return .nostrAvailable
|
||||
} else {
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
/// The Nostr key a private envelope would actually be sealed to, if any.
|
||||
var reachableNostrPublicKey: String? {
|
||||
nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
var isFavorite: Bool {
|
||||
favoriteStatus?.isFavorite ?? false
|
||||
|
||||
@ -1001,8 +1001,7 @@ final class NoiseSessionManager {
|
||||
0,
|
||||
recentInitiatorCompletionGracePeriod - elapsed
|
||||
)
|
||||
let timeout = DispatchWorkItem(flags: .barrier) {
|
||||
[weak self, weak establishedSession] in
|
||||
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in
|
||||
guard let self,
|
||||
let establishedSession,
|
||||
let current = self.sessions[peerID],
|
||||
@ -1126,8 +1125,8 @@ final class NoiseSessionManager {
|
||||
|
||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||
/// also accepted by internal callers when they exactly match the static
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||
/// key. Anything else fails closed: an identifier that can't be checked
|
||||
/// against the remote static key must never complete a handshake.
|
||||
private func authenticatedRemoteKey(
|
||||
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID
|
||||
@ -1139,7 +1138,7 @@ final class NoiseSessionManager {
|
||||
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||
return claimedNoiseKey == rawKey
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@ -76,10 +76,9 @@ final class NostrIdentityBridge {
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// CryptoKit key generation cannot fail, unlike SecRandomCopyBytes —
|
||||
// a discarded failure here would persist an all-zero identity seed.
|
||||
let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
|
||||
@ -836,9 +836,12 @@ struct NostrEvent: Codable {
|
||||
// Sign with Schnorr (BIP-340)
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
let auxStatus = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
guard auxStatus == errSecSuccess else {
|
||||
throw NostrError.cryptographicFailure
|
||||
}
|
||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
|
||||
@ -4027,10 +4027,8 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
}
|
||||
service.onRekeyHandshakeReady = {
|
||||
[weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
@ -4046,14 +4044,12 @@ extension BLEService {
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onHandshakeRecoveryRequired = {
|
||||
[weak self, weak service] request in
|
||||
service.onHandshakeRecoveryRequired = { [weak self, weak service] request in
|
||||
guard let self, let service else { return }
|
||||
#if DEBUG
|
||||
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
|
||||
#endif
|
||||
self.messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
self.messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
@ -5294,8 +5290,7 @@ extension BLEService {
|
||||
) else {
|
||||
return
|
||||
}
|
||||
messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
|
||||
@ -216,6 +216,10 @@ enum TransportConfig {
|
||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||
static let nostrGeohashSampleLimit: Int = 100
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||
// Tolerated clock skew for the client-side rumor-timestamp window on
|
||||
// inbound Nostr DMs (senders stamp the inner rumor with real time; only
|
||||
// the outer gift wrap is randomized per NIP-17).
|
||||
static let nostrDMMaxClockSkewSeconds: TimeInterval = 900
|
||||
// A sampled chat message this recent means "a conversation is happening
|
||||
// there" for the empty-timeline nearby-activity hint.
|
||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||
|
||||
@ -84,7 +84,8 @@ final class VerificationService {
|
||||
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess else { return nil }
|
||||
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()
|
||||
@ -105,9 +106,10 @@ final class VerificationService {
|
||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
||||
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||
// Freshness
|
||||
// Freshness, in both directions: a future-dated timestamp must not
|
||||
// buy a QR a longer validity window than a fresh one gets.
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
if abs(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 transport = transport else { return nil }
|
||||
|
||||
@ -108,6 +108,6 @@ struct QuickJoinSuggestion {
|
||||
"UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd",
|
||||
"VE": "d9", "VI": "de", "VN": "w7", "VU": "rs",
|
||||
"WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj",
|
||||
"ZA": "ke", "ZM": "kt", "ZW": "ks",
|
||||
"ZA": "ke", "ZM": "kt", "ZW": "ks"
|
||||
]
|
||||
}
|
||||
|
||||
@ -40,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject {
|
||||
/// Schedules main-actor work after a UI-timing delay. Injected so tests
|
||||
/// can run the work synchronously instead of polling wall-clock queues.
|
||||
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
|
||||
func addSystemMessage(_ content: String)
|
||||
|
||||
// MARK: Peers & sessions
|
||||
func peerNickname(for peerID: PeerID) -> String?
|
||||
@ -55,13 +54,10 @@ protocol ChatLifecycleContext: AnyObject {
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||
@discardableResult
|
||||
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
|
||||
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
|
||||
// MARK: Nostr & geohash
|
||||
var isTeleported: Bool { get }
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
|
||||
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
@ -80,10 +76,10 @@ extension ChatViewModel: ChatLifecycleContext {
|
||||
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
|
||||
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
|
||||
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
|
||||
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
|
||||
// `markPrivateChatRead(_:)`,
|
||||
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
||||
// the routing/ack members, `isTeleported`,
|
||||
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
||||
// the routing/ack members,
|
||||
// `deriveNostrIdentity(forGeohash:)`,
|
||||
// and `favoriteRelationship(forNoiseKey:)`
|
||||
// are shared requirements with the other contexts or satisfied by
|
||||
// existing `ChatViewModel` members. The members below flatten nested
|
||||
@ -143,34 +139,21 @@ final class ChatLifecycleCoordinator {
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
// Public channels never announce screenshots. The old broadcast told
|
||||
// everyone in radio range — and, on geohash channels, public Nostr
|
||||
// relays, permanently — that this nickname was present and active
|
||||
// here right now. Documenting something (or someone) is a core use
|
||||
// of a protest app; it must not out the person doing it. Screenshot
|
||||
// notices remain a DM-only courtesy between the two people involved.
|
||||
guard let peerID = context.selectedPrivateChatPeer else { return }
|
||||
|
||||
let screenshotMessage = "* \(context.nickname) took a screenshot *"
|
||||
|
||||
if let peerID = context.selectedPrivateChatPeer {
|
||||
sendPrivateScreenshotNotificationIfPossible(
|
||||
screenshotMessage,
|
||||
to: peerID
|
||||
)
|
||||
// Only echo "you took a screenshot" when the peer was actually
|
||||
// notified — the unconditional echo used to imply a notice that
|
||||
// frequently was never sent (no established session).
|
||||
if sendPrivateScreenshotNotificationIfPossible(screenshotMessage, to: peerID) {
|
||||
appendPrivateScreenshotNotice(for: peerID)
|
||||
return
|
||||
}
|
||||
|
||||
switch context.activeChannel {
|
||||
case .mesh:
|
||||
context.sendMeshMessage(
|
||||
screenshotMessage,
|
||||
mentions: [],
|
||||
messageID: UUID().uuidString,
|
||||
timestamp: Date()
|
||||
)
|
||||
|
||||
case .location(let channel):
|
||||
sendPublicGeohashScreenshotMessage(
|
||||
screenshotMessage,
|
||||
channel: channel
|
||||
)
|
||||
}
|
||||
|
||||
context.addSystemMessage("you took a screenshot")
|
||||
}
|
||||
|
||||
func saveIdentityState() {
|
||||
@ -293,8 +276,10 @@ final class ChatLifecycleCoordinator {
|
||||
}
|
||||
|
||||
private extension ChatLifecycleCoordinator {
|
||||
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
|
||||
guard let peerNickname = context.peerNickname(for: peerID) else { return }
|
||||
/// Returns whether the notice actually went out, so the caller can keep
|
||||
/// the local echo honest.
|
||||
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) -> Bool {
|
||||
guard let peerNickname = context.peerNickname(for: peerID) else { return false }
|
||||
|
||||
let sessionState = context.noiseSessionState(for: peerID)
|
||||
switch sessionState {
|
||||
@ -305,12 +290,14 @@ private extension ChatLifecycleCoordinator {
|
||||
recipientNickname: peerNickname,
|
||||
messageID: UUID().uuidString
|
||||
)
|
||||
return true
|
||||
|
||||
case .none, .failed, .handshakeQueued, .handshaking:
|
||||
SecureLogger.debug(
|
||||
"Skipping screenshot notification to \(peerID) - no established session",
|
||||
category: .security
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -329,37 +316,6 @@ private extension ChatLifecycleCoordinator {
|
||||
context.appendPrivateMessage(notice, to: peerID)
|
||||
}
|
||||
|
||||
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
||||
Task { @MainActor [weak context = self.context] in
|
||||
guard let context else { return }
|
||||
|
||||
do {
|
||||
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
||||
content: message,
|
||||
geohash: channel.geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: context.nickname,
|
||||
teleported: context.isTeleported
|
||||
)
|
||||
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||
if targetRelays.isEmpty {
|
||||
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
|
||||
} else {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
}
|
||||
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||
context.addSystemMessage(
|
||||
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deliveryStatusRank(_ status: DeliveryStatus) -> Int {
|
||||
switch status {
|
||||
case .notSentYet: return 0
|
||||
|
||||
@ -1859,8 +1859,7 @@ private extension ChatMediaTransferCoordinator {
|
||||
TimeInterval(UInt64.max) / 1_000_000_000
|
||||
) * 1_000_000_000
|
||||
)
|
||||
reconnectRetryExpiryTasks[messageID] = Task {
|
||||
@MainActor [weak self] in
|
||||
reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in
|
||||
if nanoseconds > 0 {
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
|
||||
@ -68,7 +68,10 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
// Private rows render a filled SF Symbol seal beside the lock
|
||||
// (TextMessageView / MediaMessageView); skip the in-string ✓ there
|
||||
// so verified DMs don't show two markers.
|
||||
if isVerifiedSender, !message.isPrivate {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
@ -388,7 +391,7 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender {
|
||||
if isVerifiedSender, !message.isPrivate {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
@ -440,7 +440,13 @@ final class ChatPeerIdentityCoordinator {
|
||||
return cachedStatus
|
||||
}
|
||||
|
||||
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
||||
// The status must reflect the LIVE session, never history. The old
|
||||
// mapping returned secured/verified for any peer whose fingerprint was
|
||||
// ever persisted — so after a cold launch or a handshake FAILURE the
|
||||
// DM header still showed a solid lock and the composer still claimed
|
||||
// "end-to-end encrypted" with no secure session in existence. A
|
||||
// remembered fingerprint changes what an established session upgrades
|
||||
// to (verified vs secured); it must not conjure a lock on its own.
|
||||
let sessionState = context.noiseSessionState(for: peerID)
|
||||
|
||||
let status: EncryptionStatus
|
||||
@ -448,11 +454,11 @@ final class ChatPeerIdentityCoordinator {
|
||||
case .established:
|
||||
status = verifiedEncryptionStatus(for: peerID)
|
||||
case .handshaking, .handshakeQueued:
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
|
||||
status = .noiseHandshaking
|
||||
case .none:
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
|
||||
status = .noHandshake
|
||||
case .failed:
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
|
||||
status = .none
|
||||
}
|
||||
|
||||
context.setCachedEncryptionStatus(status, for: peerID)
|
||||
|
||||
@ -294,7 +294,8 @@ final class ChatVerificationCoordinator {
|
||||
}
|
||||
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess else { return false }
|
||||
var pending = PendingVerification(
|
||||
noiseKeyHex: qr.noiseKeyHex,
|
||||
signKeyHex: qr.signKeyHex,
|
||||
|
||||
@ -357,6 +357,13 @@ final class NostrInboundPipeline {
|
||||
return
|
||||
}
|
||||
|
||||
guard Self.isPlausibleRumorTimestamp(rumorTs) else {
|
||||
if verbose {
|
||||
SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
SecureLogger.debug(
|
||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
@ -445,6 +452,11 @@ final class NostrInboundPipeline {
|
||||
recipientIdentity: currentIdentity
|
||||
)
|
||||
|
||||
guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else {
|
||||
SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if content.hasPrefix("verify:") {
|
||||
return
|
||||
}
|
||||
@ -542,6 +554,22 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
extension NostrInboundPipeline {
|
||||
/// Client-side mirror of the relay-side `since` filter on DM
|
||||
/// subscriptions: a relay that ignores `since` — or replays archived
|
||||
/// events — must not inject stale or future-dated DMs. The inner rumor
|
||||
/// timestamp is the sender's true send time (only the outer gift wrap
|
||||
/// is randomized per NIP-17), so the plausible window is the
|
||||
/// subscription lookback plus tolerated clock skew on both ends.
|
||||
/// Internal (not private) so tests can pin the window directly.
|
||||
static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool {
|
||||
let age = now.timeIntervalSince1970 - TimeInterval(ts)
|
||||
return age >= -TransportConfig.nostrDMMaxClockSkewSeconds
|
||||
&& age <= TransportConfig.nostrDMSubscribeLookbackSeconds
|
||||
+ TransportConfig.nostrDMMaxClockSkewSeconds
|
||||
}
|
||||
}
|
||||
|
||||
private extension NostrInboundPipeline {
|
||||
@MainActor
|
||||
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
||||
|
||||
@ -50,6 +50,15 @@ struct TextMessageView: View {
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
if conversationUIModel.showsVerifiedSeal(for: message) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.green.opacity(0.85))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
|
||||
)
|
||||
}
|
||||
if message.isBridged {
|
||||
Image(systemName: "network")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
|
||||
@ -2,6 +2,9 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@ -29,7 +32,7 @@ struct ContentComposerView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
|
||||
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in
|
||||
Button(action: {
|
||||
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
|
||||
}) {
|
||||
@ -43,6 +46,11 @@ struct ContentComposerView: View {
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
index == conversationUIModel.selectedAutocompleteIndex
|
||||
? palette.secondary.opacity(0.15)
|
||||
: Color.clear
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@ -73,7 +81,28 @@ struct ContentComposerView: View {
|
||||
.textInputAutocapitalization(.sentences)
|
||||
#endif
|
||||
.submitLabel(.send)
|
||||
.modifier(AutocompleteKeyboardNavigationModifier(
|
||||
isActive: { conversationUIModel.showAutocomplete
|
||||
&& !conversationUIModel.autocompleteSuggestions.isEmpty },
|
||||
onMove: { delta in
|
||||
conversationUIModel.moveAutocompleteSelection(by: delta)
|
||||
},
|
||||
onAccept: {
|
||||
conversationUIModel.completeSelectedSuggestion(in: &messageText)
|
||||
},
|
||||
onDismiss: {
|
||||
conversationUIModel.dismissAutocomplete()
|
||||
}
|
||||
))
|
||||
// Return while the mention panel is open completes the
|
||||
// highlight instead of sending — matches command suggestions
|
||||
// (#1504) and keeps Tab/Return/Escape on one convention.
|
||||
.onSubmit {
|
||||
if conversationUIModel.showAutocomplete,
|
||||
!conversationUIModel.autocompleteSuggestions.isEmpty,
|
||||
conversationUIModel.completeSelectedSuggestion(in: &messageText) {
|
||||
return
|
||||
}
|
||||
onSendMessage()
|
||||
// Only the return-key path: it steals focus on iOS, so
|
||||
// every message would cost a tap to reopen the keyboard.
|
||||
@ -374,3 +403,104 @@ private extension ContentComposerView {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Arrow/Tab/Return/Escape navigation for the mention suggestion list.
|
||||
///
|
||||
/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 /
|
||||
/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on
|
||||
/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself,
|
||||
/// so arrow keys never reach SwiftUI while the composer has focus — the
|
||||
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
|
||||
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
|
||||
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
|
||||
/// Live activity check, not a captured Bool. The macOS monitor closure is
|
||||
/// registered once for the view's lifetime; a plain `Bool` would freeze
|
||||
/// the value captured at install time (this is a value type), so a panel
|
||||
/// that opens after the monitor installs would never intercept a key.
|
||||
/// The provider closes over the reference-typed model and reads current
|
||||
/// state on every event.
|
||||
let isActive: () -> Bool
|
||||
let onMove: (Int) -> Void
|
||||
let onAccept: () -> Bool
|
||||
let onDismiss: () -> Void
|
||||
|
||||
#if os(macOS)
|
||||
@State private var keyMonitor: Any?
|
||||
#endif
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
#if os(macOS)
|
||||
content
|
||||
.onAppear { installKeyMonitor() }
|
||||
.onDisappear { removeKeyMonitor() }
|
||||
#else
|
||||
if #available(iOS 17.0, *) {
|
||||
content
|
||||
.onKeyPress(.upArrow) {
|
||||
guard isActive() else { return .ignored }
|
||||
onMove(-1)
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.downArrow) {
|
||||
guard isActive() else { return .ignored }
|
||||
onMove(1)
|
||||
return .handled
|
||||
}
|
||||
.onKeyPress(.tab) {
|
||||
guard isActive() else { return .ignored }
|
||||
return onAccept() ? .handled : .ignored
|
||||
}
|
||||
.onKeyPress(.escape) {
|
||||
guard isActive() else { return .ignored }
|
||||
onDismiss()
|
||||
return .handled
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
private func installKeyMonitor() {
|
||||
guard keyMonitor == nil else { return }
|
||||
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
|
||||
handleKeyDown(event)
|
||||
}
|
||||
}
|
||||
|
||||
private func removeKeyMonitor() {
|
||||
if let keyMonitor {
|
||||
NSEvent.removeMonitor(keyMonitor)
|
||||
}
|
||||
keyMonitor = nil
|
||||
}
|
||||
|
||||
/// Standard autocomplete navigation (aligned with #1504): arrows move
|
||||
/// the highlight, return/tab insert, escape dismisses. Returning nil
|
||||
/// consumes the event so return completes instead of sending while the
|
||||
/// list is up. Inactive monitors pass everything through.
|
||||
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
|
||||
guard isActive(),
|
||||
event.modifierFlags.isDisjoint(with: [.command, .option, .control]) else {
|
||||
return event
|
||||
}
|
||||
|
||||
switch event.keyCode {
|
||||
case 126: // up arrow
|
||||
onMove(-1)
|
||||
return nil
|
||||
case 125: // down arrow
|
||||
onMove(1)
|
||||
return nil
|
||||
case 36, 48: // return, tab
|
||||
return onAccept() ? nil : event
|
||||
case 53: // escape
|
||||
onDismiss()
|
||||
return nil
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -365,6 +365,16 @@ private struct ContentPeopleListView: View {
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
// Direct conversations survive channel switches; the
|
||||
// geoDM someone opened from another cell must stay
|
||||
// reachable here too.
|
||||
RecentChatList(
|
||||
chats: peerListModel.recentChatRows,
|
||||
onTapChat: { peerID in
|
||||
peerListModel.startConversation(with: peerID)
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
} else {
|
||||
PeopleSectionHeader(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
@ -400,6 +410,16 @@ private struct ContentPeopleListView: View {
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
// Conversations with people no roster above lists
|
||||
// anymore — without this, a read DM from an offline
|
||||
// non-favorite had no row anywhere in the UI.
|
||||
RecentChatList(
|
||||
chats: peerListModel.recentChatRows,
|
||||
onTapChat: { peerID in
|
||||
peerListModel.startConversation(with: peerID)
|
||||
showSidebar = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 4)
|
||||
@ -615,7 +635,9 @@ private struct ContentPrivateChatSheetView: View {
|
||||
// Geohash DMs use BitChat's private-envelope encryption over Nostr —
|
||||
// always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
// "encrypted" claim only once the Noise handshake has secured — or
|
||||
// when the peer is reachable only over Nostr, where delivery is
|
||||
// gift-wrapped end-to-end without a Noise session.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let noiseSecured: Bool = {
|
||||
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||
@ -623,7 +645,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable
|
||||
if isGeoDM || noiseSecured || nostrTransport {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
|
||||
@ -27,6 +27,9 @@ struct LocationChannelsSheet: View {
|
||||
static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find"
|
||||
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||
// Same string the settings pane shows under the tor toggle — the
|
||||
// warning belongs wherever the exposure is about to happen.
|
||||
static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address")
|
||||
|
||||
static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region")
|
||||
static func quickJoinDescription(_ regionName: String) -> String {
|
||||
@ -126,6 +129,16 @@ struct LocationChannelsSheet: View {
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
// The description's tor claim is only true while tor is on;
|
||||
// when it's off, say what that exposes right where the person
|
||||
// is about to join a channel, not just in settings.
|
||||
if !locationChannelsModel.userTorEnabled {
|
||||
Text(verbatim: Strings.torOffWarning)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.alertRed)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Group {
|
||||
switch locationChannelsModel.permissionState {
|
||||
case .notDetermined:
|
||||
|
||||
@ -48,6 +48,15 @@ struct MediaMessageView: View {
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
if conversationUIModel.showsVerifiedSeal(for: message) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.green.opacity(0.85))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verified_sender", defaultValue: "Verified sender", comment: "Accessibility label for the seal next to a verified peer's name on a private message")
|
||||
)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||
|
||||
89
bitchat/Views/RecentChatList.swift
Normal file
89
bitchat/Views/RecentChatList.swift
Normal file
@ -0,0 +1,89 @@
|
||||
//
|
||||
// RecentChatList.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// "chats" section for the people sheet: direct conversations with people
|
||||
/// who are not in any roster above it (offline passersby, geoDMs from a
|
||||
/// channel since left). Before this section existed, those threads were
|
||||
/// unreachable the moment the unread envelope cleared — still in memory,
|
||||
/// no row anywhere in the UI. Renders nothing when there are none.
|
||||
struct RecentChatList: View {
|
||||
@ThemedPalette private var palette
|
||||
|
||||
let chats: [RecentChatRow]
|
||||
let onTapChat: (PeerID) -> Void
|
||||
|
||||
private enum Strings {
|
||||
static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation")
|
||||
}
|
||||
|
||||
/// Relative "5 min ago" stamps; the formatter is locale-aware.
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
if !chats.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Same glyph+label header shape as #mesh / groups.
|
||||
PeopleSectionHeader(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
iconColor: palette.secondary,
|
||||
title: Strings.header
|
||||
)
|
||||
|
||||
ForEach(chats) { chat in
|
||||
HStack(spacing: 4) {
|
||||
Text(verbatim: chat.displayName)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
|
||||
Spacer()
|
||||
|
||||
if chat.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help(Strings.newMessagesTooltip)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapChat(chat.peerID) }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: chat))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(Strings.openChatHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accessibilityDescription(for chat: RecentChatRow) -> String {
|
||||
var parts: [String] = [
|
||||
chat.displayName,
|
||||
Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())
|
||||
]
|
||||
if chat.hasUnread { parts.append(Strings.unread) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@ -506,6 +506,90 @@ struct AppArchitectureTests {
|
||||
#expect(chromeModel.showingFingerprintFor == nil)
|
||||
}
|
||||
|
||||
@Test("Recent chats list direct conversations with people absent from the rosters")
|
||||
@MainActor
|
||||
func peerListModelSurfacesRecentChatsForAbsentPeers() async {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let offlinePeerID = PeerID(str: "00000000000000aa")
|
||||
let groupPeerID = PeerID(groupID: Data(repeating: 0xAB, count: 16))
|
||||
|
||||
// A DM thread from a passerby who has since gone offline: not in
|
||||
// allPeers, not a favorite — previously unreachable once read.
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "dm-1",
|
||||
sender: "passerby",
|
||||
content: "hello from the train",
|
||||
timestamp: Date(timeIntervalSince1970: 100),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: offlinePeerID
|
||||
)
|
||||
], for: offlinePeerID)
|
||||
// Group threads have their own section and must not duplicate here.
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "gm-1",
|
||||
sender: "member",
|
||||
content: "group hello",
|
||||
timestamp: Date(timeIntervalSince1970: 200),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: groupPeerID
|
||||
)
|
||||
], for: groupPeerID)
|
||||
|
||||
let peerListModel = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversations: viewModel.conversations
|
||||
)
|
||||
|
||||
await waitUntil {
|
||||
peerListModel.recentChatRows.contains { $0.peerID == offlinePeerID }
|
||||
}
|
||||
|
||||
#expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID])
|
||||
#expect(peerListModel.recentChatRows.first?.lastActivity == Date(timeIntervalSince1970: 100))
|
||||
// The roster doesn't list this peer — that's exactly why the row exists.
|
||||
#expect(!peerListModel.meshRows.contains { $0.peerID == offlinePeerID })
|
||||
|
||||
// A geoDM thread resolves through the Nostr mapping (bare-key
|
||||
// fallback here), never resolveNickname's mesh-only anon fallback.
|
||||
let pubkeyHex = String(repeating: "ab", count: 32)
|
||||
let geoDMPeer = PeerID(nostr_: pubkeyHex)
|
||||
viewModel.seedPrivateChat([
|
||||
BitchatMessage(
|
||||
id: "geo-1",
|
||||
sender: "stranger",
|
||||
content: "hello from another cell",
|
||||
timestamp: Date(timeIntervalSince1970: 300),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "me",
|
||||
senderPeerID: geoDMPeer
|
||||
)
|
||||
], for: geoDMPeer)
|
||||
|
||||
await waitUntil {
|
||||
peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }
|
||||
}
|
||||
let geoRow = peerListModel.recentChatRows.first { $0.peerID == geoDMPeer }
|
||||
#expect(geoRow?.displayName == geoDMPeer.bare)
|
||||
|
||||
// While that person is visible in the geohash roster, the chat row
|
||||
// collapses — same absent-from-rosters contract as mesh.
|
||||
viewModel.participantTracker.setActiveGeohash("u4pruy")
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy")
|
||||
|
||||
await waitUntil {
|
||||
!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer }
|
||||
}
|
||||
#expect(!peerListModel.recentChatRows.contains { $0.peerID == geoDMPeer })
|
||||
#expect(peerListModel.recentChatRows.map(\.peerID) == [offlinePeerID])
|
||||
}
|
||||
|
||||
@Test("PrivateConversationModel resolves canonical header state for the selected DM")
|
||||
@MainActor
|
||||
func privateConversationModelResolvesSelectedHeaderState() async {
|
||||
|
||||
@ -36,6 +36,30 @@ struct BitchatPeerTests {
|
||||
#expect(peer.statusIcon == "🌙")
|
||||
}
|
||||
|
||||
@Test("Mutual favorite without a stored Nostr key is offline, not nostr-available")
|
||||
func mutualFavoriteWithoutNostrKeyIsOffline() {
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = FavoriteRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: nil,
|
||||
peerNickname: "A",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
// Nothing to seal a Nostr envelope to: claiming availability here
|
||||
// would relight the DM header's "end-to-end encrypted" caption with
|
||||
// neither a Noise session nor a usable recipient key.
|
||||
#expect(peer.connectionState == .offline)
|
||||
|
||||
peer.nostrPublicKey = "abcdef"
|
||||
#expect(peer.connectionState == .nostrAvailable)
|
||||
}
|
||||
|
||||
@Test("Mutual offline peers show Nostr icon")
|
||||
func mutualFavoriteOfflinePeerShowsNostrIcon() {
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
|
||||
@ -7,12 +7,10 @@
|
||||
// `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Scope note: the geohash-screenshot branch publishes via
|
||||
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
|
||||
// by the full view-model tests. The GeoDM read pass, the favorites-backed
|
||||
// mesh/Nostr read-receipt branch (favorites are injected through the
|
||||
// context), message merging, screenshot notices, and lifecycle persistence
|
||||
// flows are covered here.
|
||||
// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr
|
||||
// read-receipt branch (favorites are injected through the context), message
|
||||
// merging, DM screenshot notices, and lifecycle persistence flows are
|
||||
// covered here. Screenshots are never announced to public channels.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@ -42,7 +40,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
private(set) var ownerLevelReadPasses: [PeerID] = []
|
||||
private(set) var managerReadMarks: [PeerID] = []
|
||||
private(set) var systemMessages: [String] = []
|
||||
|
||||
// Conversation store intents
|
||||
@discardableResult
|
||||
@ -79,8 +76,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
work()
|
||||
}
|
||||
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
|
||||
// Peers & sessions
|
||||
var nicknamesByPeerID: [PeerID: String] = [:]
|
||||
var peersByID: [PeerID: BitchatPeer] = [:]
|
||||
@ -99,7 +94,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
// Routing & receipts
|
||||
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
|
||||
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var meshBroadcasts: [String] = []
|
||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
@ -112,20 +106,12 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
meshBroadcasts.append(content)
|
||||
}
|
||||
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
geoReadReceipts.append((messageID, recipientHex))
|
||||
}
|
||||
|
||||
// Nostr & geohash
|
||||
var isTeleported = false
|
||||
private(set) var recordedGeoParticipants: [String] = []
|
||||
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
|
||||
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
|
||||
|
||||
// Favorites
|
||||
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
|
||||
@ -264,40 +250,39 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
|
||||
func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async {
|
||||
let context = MockChatLifecycleContext()
|
||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.nicknamesByPeerID[peerID] = "alice"
|
||||
|
||||
// No established session: local notice only, no network send.
|
||||
// No established session: nothing goes out, so nothing is echoed —
|
||||
// a local "you took a screenshot" would imply the peer was told.
|
||||
coordinator.handleScreenshotCaptured()
|
||||
#expect(context.routedPrivateMessages.isEmpty)
|
||||
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
#expect(context.privateChats.isEmpty)
|
||||
|
||||
// Established session: the peer is notified too.
|
||||
// Established session: the peer is notified and the echo appears.
|
||||
context.noiseSessionStates[peerID] = .established
|
||||
coordinator.handleScreenshotCaptured()
|
||||
#expect(context.routedPrivateMessages.count == 1)
|
||||
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
|
||||
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
|
||||
#expect(context.privateChats[peerID]?.count == 2)
|
||||
// The public-channel system message is not used for private chats.
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
#expect(context.meshBroadcasts.isEmpty)
|
||||
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
|
||||
func handleScreenshotCaptured_publicChannel_staysSilent() async {
|
||||
let context = MockChatLifecycleContext()
|
||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||
|
||||
// No DM selected: screenshots must never announce presence to a
|
||||
// public channel (mesh broadcast or Nostr relays).
|
||||
coordinator.handleScreenshotCaptured()
|
||||
|
||||
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
|
||||
#expect(context.systemMessages == ["you took a screenshot"])
|
||||
#expect(context.routedPrivateMessages.isEmpty)
|
||||
#expect(context.privateChats.isEmpty)
|
||||
}
|
||||
|
||||
@ -365,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Screenshot Routing
|
||||
|
||||
/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path,
|
||||
/// the App Info exemption, and the deliberate mesh silence. Combined with
|
||||
/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves
|
||||
/// location + screenshot → local privacy alert, and nothing sent anywhere.
|
||||
struct ScreenshotCaptureRoutingTests {
|
||||
|
||||
@Test("Location channels warn locally; the sheet warns everywhere")
|
||||
func locationScreenshotsWarnLocally() {
|
||||
// Geohash timeline, no DM open: warn the person, tell no one.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: true
|
||||
) == .warnLocally)
|
||||
|
||||
// The channel sheet reveals location regardless of active channel.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: true,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: true,
|
||||
isLocationChannelActive: false
|
||||
) == .warnLocally)
|
||||
}
|
||||
|
||||
@Test("App Info is exempt; mesh and DMs forward to the chat layer")
|
||||
func nonLocationScreenshotsForwardOrIgnore() {
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: true,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: true
|
||||
) == .ignore)
|
||||
|
||||
// Mesh timeline: forwarded, where the coordinator stays silent for
|
||||
// public channels — no local alert either (nothing is sent and no
|
||||
// place is revealed; see ScreenshotCaptureResponse docs).
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: false
|
||||
) == .forwardToChat)
|
||||
|
||||
// An open DM keeps its peer notice even from a location channel.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: true,
|
||||
isLocationChannelActive: true
|
||||
) == .forwardToChat)
|
||||
}
|
||||
}
|
||||
|
||||
@ -653,8 +653,7 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
|
||||
async throws
|
||||
{
|
||||
async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
|
||||
@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(routed)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
// The pipeline itself stays usable: a gift wrap spawned AFTER the
|
||||
// wipe (new generation) still decrypts and delivers.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(delivered)
|
||||
}
|
||||
|
||||
|
||||
@ -360,6 +360,37 @@ struct ChatPeerIdentityCoordinatorContextTests {
|
||||
#expect(context.cachedEncryptionStatuses[peerID] == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func getEncryptionStatus_reflectsLiveSessionNotHistory() async {
|
||||
let context = MockChatPeerIdentityContext()
|
||||
let coordinator = ChatPeerIdentityCoordinator(context: context)
|
||||
let peerID = PeerID(str: "8877665544332211")
|
||||
|
||||
// A remembered (even verified) fingerprint must not conjure a lock on
|
||||
// its own: the DM header and "end-to-end encrypted" caption follow
|
||||
// this status, and claiming secured with no live session is the false
|
||||
// security signal this mapping used to produce.
|
||||
context.fingerprintsByPeerID[peerID] = "fp"
|
||||
context.verifiedFingerprintSet = ["fp"]
|
||||
|
||||
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
|
||||
|
||||
coordinator.invalidateEncryptionCache(for: peerID)
|
||||
context.noiseSessionStates[peerID] = .handshaking
|
||||
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking)
|
||||
|
||||
// A FAILED handshake is a red lock.slash, not yesterday's seal.
|
||||
struct HandshakeError: Error {}
|
||||
coordinator.invalidateEncryptionCache(for: peerID)
|
||||
context.noiseSessionStates[peerID] = .failed(HandshakeError())
|
||||
#expect(coordinator.getEncryptionStatus(for: peerID) == .none)
|
||||
|
||||
// Only a live established session upgrades to the verified seal.
|
||||
coordinator.invalidateEncryptionCache(for: peerID)
|
||||
context.noiseSessionStates[peerID] = .established
|
||||
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func resolveNickname_walksMeshIdentityAndAnonFallbacks() async {
|
||||
let context = MockChatPeerIdentityContext()
|
||||
|
||||
@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
viewModel.handleNostrEvent(signed)
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didStore)
|
||||
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
// (sent even for blocked senders) to know processing finished.
|
||||
let didAck = await TestHelpers.waitUntil(
|
||||
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didAck)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
// Media sends hop through Task.detached; the global executor is
|
||||
// shared with every parallel test worker, so a loaded runner can
|
||||
// exceed the 5s default. waitUntil returns as soon as the condition
|
||||
// holds, so passing runs never pay the longer timeout.
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
// shared with every parallel test worker, so a loaded runner can be
|
||||
// starved for seconds. waitUntil returns as soon as the condition
|
||||
// holds, so passing runs never pay the settle deadline.
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||
let showedSecond = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(showedSecond)
|
||||
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
)
|
||||
let advanced = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(advanced)
|
||||
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||
@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
|
||||
@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
|
||||
func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
@ -437,8 +437,10 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.handleScreenshotCaptured()
|
||||
|
||||
// No session means no notice went out, so no local echo either —
|
||||
// an echo here would imply Alice was told when she wasn't.
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
|
||||
#expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -115,13 +115,18 @@ struct PrivateChatE2ETests {
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
|
||||
// Manager sessions are keyed by key-derived wire IDs: handshake
|
||||
// completion fails closed on IDs the remote key can't vouch for.
|
||||
let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
|
||||
let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
|
||||
// Establish encrypted session
|
||||
do {
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3)
|
||||
} catch {
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
@ -134,7 +139,7 @@ struct PrivateChatE2ETests {
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
@ -155,7 +160,7 @@ struct PrivateChatE2ETests {
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
#expect(message.content == TestConstants.testMessage1)
|
||||
#expect(message.isPrivate)
|
||||
|
||||
@ -18,10 +18,10 @@ struct IntegrationTests {
|
||||
private var helper = TestNetworkHelper()
|
||||
|
||||
init() {
|
||||
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Alice")
|
||||
helper.createNode("Bob")
|
||||
helper.createNode("Charlie")
|
||||
helper.createNode("David")
|
||||
}
|
||||
|
||||
// MARK: - Multi-Peer Scenarios
|
||||
@ -266,9 +266,11 @@ struct IntegrationTests {
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
|
||||
// Simulate Bob restart by recreating his Noise manager
|
||||
// Simulate Bob restart by recreating his Noise manager. A new static
|
||||
// key means a new key-derived wire ID, just like production.
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||
helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
@ -320,7 +322,7 @@ struct IntegrationTests {
|
||||
@Test func largeScaleNetwork() async throws {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||
helper.createNode("Node\(i)")
|
||||
}
|
||||
|
||||
// Connect in ring topology with cross-connections
|
||||
|
||||
@ -37,7 +37,7 @@ struct LargeTopologyTests {
|
||||
|
||||
private func makeNodes(_ names: [String]) {
|
||||
for name in names {
|
||||
helper.createNode(name, peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,15 +22,17 @@ final class TestNetworkHelper {
|
||||
// MARK: - Node/Manager management
|
||||
|
||||
@discardableResult
|
||||
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||
func createNode(_ name: String) -> MockBLEService {
|
||||
let node = MockBLEService(bus: bus)
|
||||
node.myPeerID = peerID
|
||||
// Wire IDs must derive from the node's Noise static key: handshake
|
||||
// completion fails closed on IDs the remote key can't vouch for.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation)
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
|
||||
// This synchronous helper directly drives all three XX messages and
|
||||
// has no transport callback loop for delayed collision recovery.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(
|
||||
localStaticKey: key,
|
||||
keychain: mockKeychain,
|
||||
|
||||
@ -578,6 +578,26 @@ struct NoiseCoverageTests {
|
||||
#expect(failingManager.getSession(for: charliePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Handshake completion fails closed on non-wire peer IDs")
|
||||
func handshakeCompletionRejectsNonWirePeerIDs() throws {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
|
||||
// Alice addresses Bob by an identifier no static key can vouch for:
|
||||
// neither a 16-hex wire ID nor a full Noise-key ID. Completion must
|
||||
// reject it rather than accept any remote static key.
|
||||
let nonWireID = PeerID(str: "not-a-wire-identifier")
|
||||
let msg1 = try aliceManager.initiateHandshake(with: nonWireID)
|
||||
let msg2 = try #require(
|
||||
try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)
|
||||
)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2)
|
||||
}
|
||||
#expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true)
|
||||
}
|
||||
|
||||
@Test("Session manager cleans up initiator sessions after start-handshake failures")
|
||||
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
|
||||
let manager = NoiseSessionManager(
|
||||
|
||||
@ -68,22 +68,29 @@ struct NoiseProtocolTests {
|
||||
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
private let alicePeerID = PeerID(str: UUID().uuidString)
|
||||
private let bobPeerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
// Manager sessions are keyed by the remote peer. Keep the historical
|
||||
// names, but derive each wire ID from the static key that the
|
||||
// corresponding manager authenticates during the handshake.
|
||||
private var alicePeerID: PeerID {
|
||||
PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private var bobPeerID: PeerID {
|
||||
PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
private let aliceSession: NoiseSession
|
||||
private let bobSession: NoiseSession
|
||||
|
||||
|
||||
init() {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: alicePeerID,
|
||||
peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation),
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
|
||||
bobSession = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation),
|
||||
role: .responder,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: bobKey
|
||||
|
||||
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal file
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// NostrInboundPipelineTimestampTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrInboundPipelineTimestampTests {
|
||||
private let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private let nowSeconds = 1_700_000_000
|
||||
private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds)
|
||||
private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
|
||||
@Test("Rumor timestamps inside the lookback-plus-skew window are accepted")
|
||||
func acceptsPlausibleTimestamps() {
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now))
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now))
|
||||
// A sender clock slightly ahead of the receiver is tolerated.
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now))
|
||||
}
|
||||
|
||||
@Test("Future-dated and stale rumor timestamps are rejected")
|
||||
func rejectsImplausibleTimestamps() {
|
||||
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now))
|
||||
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now))
|
||||
}
|
||||
}
|
||||
@ -150,14 +150,12 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.clearedSessions.append(peerID)
|
||||
service.clearSession(for: peerID)
|
||||
},
|
||||
handleAuthenticatedPeerState: {
|
||||
peerID, payload, generation in
|
||||
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append(
|
||||
(peerID, payload, generation)
|
||||
)
|
||||
},
|
||||
deliverNoisePayload: {
|
||||
peerID, type, payload, timestamp in
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append(
|
||||
(peerID, type, payload, timestamp)
|
||||
)
|
||||
|
||||
@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase {
|
||||
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsFutureDatedPayload() throws {
|
||||
let (service, noise) = makeService()
|
||||
let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970)
|
||||
let qrString = try makeSignedQR(
|
||||
noise: noise,
|
||||
nickname: "future-\(UUID().uuidString)",
|
||||
npub: nil,
|
||||
ts: futureTimestamp
|
||||
)
|
||||
|
||||
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsTamperedSignature() throws {
|
||||
let (service, noise) = makeService()
|
||||
let badSignature = Data(repeating: 0xAA, count: 64)
|
||||
|
||||
@ -56,7 +56,21 @@ struct SimulatedMeshTests {
|
||||
mesh.connect(1, 2)
|
||||
|
||||
mesh.announceAll()
|
||||
mesh.advanceTime(by: 2)
|
||||
// Discovery is not quiet after one advance: every first-seen peer
|
||||
// schedules an afterglow re-announce at a RANDOM 0.3–0.6s delay
|
||||
// (BLEAnnounceHandler), and each of those can cascade another relay
|
||||
// round. Whether that traffic lands before or after a one-shot
|
||||
// baseline snapshot depends on the draw — the budget assertion below
|
||||
// flaked on CI at 14 and 18 frames for exactly that reason. Advance
|
||||
// until the mesh goes a full window with no new frames, so the
|
||||
// baseline only ever measures the message under test.
|
||||
var settled = mesh.deliveredFrameCount
|
||||
for _ in 0..<20 {
|
||||
mesh.advanceTime(by: 2)
|
||||
let now = mesh.deliveredFrameCount
|
||||
if now == settled { break }
|
||||
settled = now
|
||||
}
|
||||
let baseline = mesh.deliveredFrameCount
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// periphery:ignore - intentionally unreferenced by production code; nothing
|
||||
// emits or consumes this type yet, and BLEService parses it only to ignore it.
|
||||
// Delete this annotation when the mesh starts using it.
|
||||
/// Identity-free presence announcement for rotating peer IDs.
|
||||
///
|
||||
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the
|
||||
@ -37,9 +40,6 @@ import Foundation
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet.
|
||||
// periphery:ignore - intentionally unreferenced by production code; nothing
|
||||
// emits or consumes this type yet, and BLEService parses it only to ignore it.
|
||||
// Delete this annotation when the mesh starts using it.
|
||||
public struct AnnounceV2Packet: Equatable, Sendable {
|
||||
/// Rotation epoch this announce was built for. Carried explicitly so a
|
||||
/// receiver matches against a stated epoch instead of guessing.
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
import Foundation
|
||||
private import CryptoKit
|
||||
|
||||
// periphery:ignore - intentionally unreferenced by production code. These are
|
||||
// the reviewable primitives for a protocol change that cannot ship until both
|
||||
// platforms agree on it; wiring them into the transport is the next step, not
|
||||
// this one. Delete this annotation when the mesh starts using them.
|
||||
/// Derivations for rotating peer IDs and pairwise recognition tags.
|
||||
///
|
||||
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
|
||||
@ -32,10 +36,6 @@ private import CryptoKit
|
||||
/// Recognition tags come from the X25519 shared secret between two static
|
||||
/// keys, so exactly two parties can compute a given tag and an observer can
|
||||
/// compute none of them.
|
||||
// periphery:ignore - intentionally unreferenced by production code. These are
|
||||
// the reviewable primitives for a protocol change that cannot ship until both
|
||||
// platforms agree on it; wiring them into the transport is the next step, not
|
||||
// this one. Delete this annotation when the mesh starts using them.
|
||||
public enum PeerIDRotation {
|
||||
// MARK: - Parameters
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user