mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Compare commits
No commits in common. "main" and "v1.7.1" have entirely different histories.
8
.github/workflows/periphery.yml
vendored
8
.github/workflows/periphery.yml
vendored
@ -11,11 +11,9 @@ jobs:
|
||||
name: Periphery scan
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# 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).
|
||||
# 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
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
13
.github/workflows/swift-tests.yml
vendored
13
.github/workflows/swift-tests.yml
vendored
@ -264,13 +264,11 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
lint:
|
||||
name: SwiftLint
|
||||
name: SwiftLint (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
@ -282,9 +280,10 @@ 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 --strict --reporter github-actions-logging
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .device-lab
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
@ -22,7 +21,6 @@ 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
|
||||
|
||||
@ -17,16 +17,7 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showBluetoothAlert = false
|
||||
@Published var bluetoothAlertMessage = ""
|
||||
@Published var bluetoothState: CBManagerState = .unknown
|
||||
/// Tor bootstrap has stalled (network likely blocks it); drives the
|
||||
/// connectivity banner. Mirrored from `ChatViewModel.torBlocked`.
|
||||
@Published private(set) var torBlocked = false
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
/// Triple-tapping the logo asks first; the dialog lives on the header.
|
||||
@Published var showPanicConfirmation = false
|
||||
/// Mirrors `ChatViewModel.panicRecoveryBlocked` for the chrome: a wipe
|
||||
/// that did not commit must be visible, not just logged — the person who
|
||||
/// triggered it needs to know data may remain on the device.
|
||||
@Published private(set) var panicWipeBlocked = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
@ -120,24 +111,7 @@ final class AppChromeModel: ObservableObject {
|
||||
prepareForPanic = preparation
|
||||
}
|
||||
|
||||
/// Entry point for the header triple-tap: confirm before destroying.
|
||||
/// The Settings-pane button has always confirmed; the gesture now goes
|
||||
/// through the same dialog so a mis-tap can't wipe the device.
|
||||
func requestPanicWipe() {
|
||||
showPanicConfirmation = true
|
||||
}
|
||||
|
||||
func panicClearAllData() {
|
||||
// A wipe invalidates everything on screen, and its outcome must be
|
||||
// visible: the success message and the failed-wipe banner both live
|
||||
// on the root timeline, so a sheet left up (the App Info danger-zone
|
||||
// path keeps its sheet presented) would hide the one signal that says
|
||||
// whether the wipe worked.
|
||||
isAppInfoPresented = false
|
||||
isLocationChannelsSheetPresented = false
|
||||
isNoticesSheetPresented = false
|
||||
showingFingerprintFor = nil
|
||||
|
||||
prepareForPanic?()
|
||||
onPanicWipe()
|
||||
chatViewModel.panicClearAllData()
|
||||
@ -171,14 +145,6 @@ final class AppChromeModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$bluetoothState)
|
||||
|
||||
chatViewModel.$torBlocked
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$torBlocked)
|
||||
|
||||
chatViewModel.$panicRecoveryBlocked
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$panicWipeBlocked)
|
||||
|
||||
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@ -424,24 +424,16 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func handleScreenshotCaptured() {
|
||||
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:
|
||||
if appChromeModel.isLocationChannelsSheetPresented {
|
||||
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||
case .ignore:
|
||||
break
|
||||
case .forwardToChat:
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
return
|
||||
}
|
||||
|
||||
if appChromeModel.isAppInfoPresented {
|
||||
return
|
||||
}
|
||||
|
||||
chatViewModel.handleScreenshotCaptured()
|
||||
}
|
||||
|
||||
func openExternalURL(_ url: URL) {
|
||||
@ -458,40 +450,3 @@ 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,7 +9,6 @@ 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
|
||||
@ -37,7 +36,6 @@ 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()
|
||||
@ -106,31 +104,6 @@ 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)
|
||||
}
|
||||
@ -155,18 +128,6 @@ 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)
|
||||
@ -232,10 +193,6 @@ 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)
|
||||
@ -262,15 +219,6 @@ 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,27 +39,12 @@ 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
|
||||
@ -158,17 +143,6 @@ 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,7 +213,7 @@ final class PeerListModel: ObservableObject {
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
displayName: isMe ? chatViewModel.nickname : peer.displayName,
|
||||
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||
isMe: isMe,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||
@ -265,7 +239,6 @@ final class PeerListModel: ObservableObject {
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
@ -273,111 +246,19 @@ 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)"
|
||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
} +
|
||||
geohashPeople.map {
|
||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||
} +
|
||||
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
|
||||
|
||||
@ -294,21 +294,6 @@ final class PrivateConversationModel: ObservableObject {
|
||||
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||
}
|
||||
// Local alias wins over a live peer row's announced nickname.
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let identity = candidates.first,
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
|
||||
let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
} else if let noiseKey = headerPeerID.noiseKey {
|
||||
let fingerprint = noiseKey.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
|
||||
let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
}
|
||||
if let displayName = peer?.displayName {
|
||||
return displayName
|
||||
}
|
||||
@ -323,15 +308,23 @@ final class PrivateConversationModel: ObservableObject {
|
||||
if headerPeerID.id.count == 16 {
|
||||
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||
if let identity = candidates.first,
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint),
|
||||
!social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
} else if let noiseKey = headerPeerID.noiseKey {
|
||||
let fingerprint = noiseKey.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint),
|
||||
!social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -357,10 +350,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual,
|
||||
// No stored recipient key means no Nostr delivery — and the
|
||||
// "end-to-end encrypted" caption keys off .nostrAvailable.
|
||||
favoriteStatus.peerNostrPublicKey != nil {
|
||||
favoriteStatus.isMutual {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
|
||||
@ -8,9 +8,6 @@ struct FingerprintPresentationState: Equatable {
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
/// User-assigned local alias (petname), if any — distinct from the
|
||||
/// peer-claimed nickname.
|
||||
let localPetname: String?
|
||||
/// Number of currently-valid vouches from peers the user verified
|
||||
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||
let voucherCount: Int
|
||||
@ -23,11 +20,6 @@ struct FingerprintPresentationState: Equatable {
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
}
|
||||
|
||||
/// Alias field is editable once we know who we're looking at.
|
||||
var canEditLocalAlias: Bool {
|
||||
theirFingerprint != nil
|
||||
}
|
||||
}
|
||||
|
||||
enum VerificationScanOutcome: Equatable {
|
||||
@ -83,46 +75,6 @@ final class VerificationModel: ObservableObject {
|
||||
chatViewModel.unverifyFingerprint(for: peerID)
|
||||
}
|
||||
|
||||
/// Persist a local alias for this peer. Empty/whitespace clears it so the
|
||||
/// claimed nickname shows again. Display paths prefer `localPetname`
|
||||
/// when set (#1439).
|
||||
func setLocalPetname(_ petname: String?, for peerID: PeerID) {
|
||||
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: statusPeerID) else { return }
|
||||
|
||||
let trimmed = petname?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized: String? = (trimmed?.isEmpty == false) ? trimmed : nil
|
||||
|
||||
let existing = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)
|
||||
let claimed = existing?.claimedNickname
|
||||
?? chatViewModel.meshService.peerNickname(peerID: statusPeerID)
|
||||
?? chatViewModel.resolveNickname(for: statusPeerID)
|
||||
var identity = existing ?? SocialIdentity(
|
||||
fingerprint: fingerprint,
|
||||
localPetname: nil,
|
||||
claimedNickname: claimed,
|
||||
trustLevel: .unknown,
|
||||
isFavorite: false,
|
||||
isBlocked: false,
|
||||
notes: nil
|
||||
)
|
||||
identity.localPetname = normalized
|
||||
// Prefer the mesh-announced name for claimedNickname so we don't
|
||||
// persist a previous alias as the "claimed" identity.
|
||||
if let announced = chatViewModel.meshService.peerNickname(peerID: statusPeerID),
|
||||
!announced.isEmpty {
|
||||
identity.claimedNickname = announced
|
||||
} else if identity.claimedNickname.isEmpty {
|
||||
identity.claimedNickname = claimed
|
||||
}
|
||||
chatViewModel.identityManager.updateSocialIdentity(identity)
|
||||
// Rebuild peer rows so PeerList / DM header pick up the new display name
|
||||
// without waiting for an unrelated mesh event.
|
||||
chatViewModel.unifiedPeerService.refreshPeers()
|
||||
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
|
||||
objectWillChange.send()
|
||||
}
|
||||
|
||||
func isVerified(peerID: PeerID) -> Bool {
|
||||
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||
return peerIdentityStore.isVerified(fingerprint)
|
||||
@ -134,8 +86,6 @@ final class VerificationModel: ObservableObject {
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
let localPetname = theirFingerprint
|
||||
.flatMap { chatViewModel.identityManager.getSocialIdentity(for: $0)?.localPetname }
|
||||
|
||||
// Vouch state is recomputed on read: only vouchers still in the
|
||||
// verified set count, so removing a verification silently retires the
|
||||
@ -160,7 +110,6 @@ final class VerificationModel: ObservableObject {
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: isVerified,
|
||||
localPetname: localPetname,
|
||||
voucherCount: vouchers.count,
|
||||
voucherNames: voucherNames
|
||||
)
|
||||
@ -209,15 +158,6 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
// Prefer an explicit local alias even when a live peer row exists —
|
||||
// peer.displayName already does this once UnifiedPeerService rebuilds,
|
||||
// but read social identity directly so the fingerprint sheet header
|
||||
// updates before that rebuild lands.
|
||||
if let fingerprint = chatViewModel.getFingerprint(for: statusPeerID),
|
||||
let pet = chatViewModel.identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
|
||||
!pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||
return peer.displayName
|
||||
}
|
||||
@ -231,6 +171,9 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
let fingerprint = data.sha256Fingerprint()
|
||||
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||
if let pet = social.localPetname, !pet.isEmpty {
|
||||
return pet
|
||||
}
|
||||
if !social.claimedNickname.isEmpty {
|
||||
return social.claimedNickname
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -15,10 +15,6 @@ struct BitchatPeer: Equatable {
|
||||
|
||||
// Nostr identity (if known)
|
||||
var nostrPublicKey: String?
|
||||
|
||||
/// Device-local alias (petname). Never sent over the wire; when set it
|
||||
/// outranks the peer-claimed `nickname` for display only.
|
||||
var localPetname: String?
|
||||
|
||||
// Connection state
|
||||
enum ConnectionState {
|
||||
@ -33,22 +29,13 @@ struct BitchatPeer: Equatable {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} 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.
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
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
|
||||
@ -64,10 +51,7 @@ struct BitchatPeer: Equatable {
|
||||
|
||||
// Display helpers
|
||||
var displayName: String {
|
||||
if let localPetname, !localPetname.isEmpty {
|
||||
return localPetname
|
||||
}
|
||||
return nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||
}
|
||||
|
||||
var statusIcon: String {
|
||||
@ -94,15 +78,13 @@ struct BitchatPeer: Equatable {
|
||||
nickname: String,
|
||||
lastSeen _: Date = Date(),
|
||||
isConnected: Bool = false,
|
||||
isReachable: Bool = false,
|
||||
localPetname: String? = nil
|
||||
isReachable: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.nickname = nickname
|
||||
self.isConnected = isConnected
|
||||
self.isReachable = isReachable
|
||||
self.localPetname = localPetname
|
||||
|
||||
// Load favorite status - will be set later by the manager
|
||||
self.favoriteStatus = nil
|
||||
|
||||
@ -1001,7 +1001,8 @@ 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],
|
||||
@ -1125,8 +1126,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. Anything else fails closed: an identifier that can't be checked
|
||||
/// against the remote static key must never complete a handshake.
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||
private func authenticatedRemoteKey(
|
||||
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID
|
||||
@ -1138,7 +1139,7 @@ final class NoiseSessionManager {
|
||||
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||
return claimedNoiseKey == rawKey
|
||||
}
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@ -76,9 +76,10 @@ final class NostrIdentityBridge {
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
// 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) }
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
|
||||
@ -836,12 +836,9 @@ struct NostrEvent: Codable {
|
||||
// Sign with Schnorr (BIP-340)
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
let auxStatus = auxRand.withUnsafeMutableBytes { ptr in
|
||||
_ = 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()
|
||||
|
||||
@ -15,15 +15,7 @@ enum BLEOutboundPacketPolicy {
|
||||
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||
// push every ~490-byte signed voice packet over the MTU into the
|
||||
// fragment path.
|
||||
//
|
||||
// announceV2 is unpadded too, but for a different reason and it is worth
|
||||
// revisiting: it is ~75 bytes, so the smallest bucket would triple the
|
||||
// airtime of the most frequently sent packet in the protocol. Its length
|
||||
// is already near-constant by construction (the tag block is fixed
|
||||
// width); the residual variation is the capability width and whether a
|
||||
// bridge geohash is present. Making those fixed-width would be cheaper
|
||||
// than padding. See docs/PEER-ID-ROTATION.md.
|
||||
case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -35,13 +27,6 @@ enum BLEOutboundPacketPolicy {
|
||||
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||
case .fileTransfer:
|
||||
return .fileTransfer
|
||||
case .announceV2:
|
||||
// Stated rather than inherited from `default`. Presence is small,
|
||||
// time-bounded to its epoch, and useless once stale, so it belongs
|
||||
// with the other control traffic at high priority — but that should
|
||||
// be a decision on the record, not a fall-through, since this type
|
||||
// is not emitted yet and nobody would notice the choice being made.
|
||||
return .high
|
||||
default:
|
||||
return .high
|
||||
}
|
||||
|
||||
@ -4027,8 +4027,10 @@ 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 {
|
||||
@ -4044,12 +4046,14 @@ 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 {
|
||||
@ -5290,7 +5294,8 @@ 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,
|
||||
@ -5978,16 +5983,7 @@ extension BLEService {
|
||||
switch context.messageType {
|
||||
case .announce:
|
||||
handleAnnounce(packet, from: senderID)
|
||||
|
||||
case .announceV2:
|
||||
// Parsed and ignored on purpose. The wire format and derivations are
|
||||
// implemented and tested (see PeerIDRotation, AnnounceV2Packet), but
|
||||
// consuming presence from it needs the replacement identity binding
|
||||
// and the peer-list policy for unverified presence, both of which are
|
||||
// still open questions in docs/PEER-ID-ROTATION.md. Accepting it now
|
||||
// would add unauthenticated entries to the peer list.
|
||||
break
|
||||
|
||||
|
||||
case .message:
|
||||
handleMessage(packet, from: senderID)
|
||||
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
//
|
||||
// ChannelShare.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Builds plain-text location-channel invites for the system share sheet (#1497).
|
||||
///
|
||||
/// Text-first on purpose: a `bitchat://` deep link is dead weight for people
|
||||
/// who have not installed yet, and SMS does not reliably linkify custom
|
||||
/// schemes. The payload always includes the App Store URL and the geohash a
|
||||
/// person can type under location channels after installing.
|
||||
enum ChannelShare {
|
||||
/// App Store listing used in out-of-app invites.
|
||||
static let appStoreURL = "https://apps.apple.com/us/app/bitchat-mesh/id6748219622"
|
||||
|
||||
/// Neighborhood (6) and finer imply a small cell — sharing that over SMS
|
||||
/// discloses location interest to the carrier and both handsets.
|
||||
static let precisionWarningMinimumLength = 6
|
||||
|
||||
static func shouldWarn(forGeohash geohash: String) -> Bool {
|
||||
geohash.count >= precisionWarningMinimumLength
|
||||
}
|
||||
|
||||
/// Channel-not-presence framing: "join #x", never "I'm in #x".
|
||||
static func payload(forGeohash geohash: String) -> String {
|
||||
let gh = geohash.lowercased()
|
||||
return String(
|
||||
format: String(
|
||||
localized: "channel.share.payload",
|
||||
defaultValue: "join the #%1$@ channel on bitchat: bitchat://geohash/%1$@ — new to bitchat? get it at %2$@ then type #%1$@ under location channels.",
|
||||
comment: "Plain-text share payload for a location channel; %1$@ is the geohash, %2$@ is the App Store URL"
|
||||
),
|
||||
locale: .current,
|
||||
gh,
|
||||
appStoreURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifiable wrapper so `.sheet(item:)` can present the system share UI.
|
||||
struct ChannelSharePayload: Identifiable {
|
||||
let id = UUID()
|
||||
let text: String
|
||||
}
|
||||
@ -105,7 +105,7 @@ final class CommandProcessor {
|
||||
@MainActor
|
||||
func process(_ command: String) -> CommandResult {
|
||||
let parts = command.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard let cmd = parts.first else { return .error(message: String(localized: "command.error.invalid", defaultValue: "invalid command", comment: "Error for an empty or unparseable slash command")) }
|
||||
guard let cmd = parts.first else { return .error(message: "Invalid command") }
|
||||
let args = parts.count > 1 ? String(parts[1]) : ""
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
@ -133,19 +133,19 @@ final class CommandProcessor {
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.groups_mesh_only", defaultValue: "groups are only for mesh peers in #mesh", comment: "Error when a group command is used outside the mesh channel")) }
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) }
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.favorites_mesh_only", defaultValue: "favorites are only for mesh peers in #mesh", comment: "Error when a favorites command is used outside the mesh channel")) }
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/ping":
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.ping_mesh_only", defaultValue: "ping only works for mesh peers in #mesh", comment: "Error when /ping is used outside the mesh channel")) }
|
||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||
return handlePing(args)
|
||||
case "/trace":
|
||||
if inGeoPublic || inGeoDM { return .error(message: String(localized: "command.error.trace_mesh_only", defaultValue: "trace only works for mesh peers in #mesh", comment: "Error when /trace is used outside the mesh channel")) }
|
||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||
return handleTrace(args)
|
||||
case "/pay":
|
||||
return handlePay(args)
|
||||
@ -154,16 +154,31 @@ final class CommandProcessor {
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: String(format: String(localized: "command.error.unknown", defaultValue: "unknown command: %@ — type /help for commands", comment: "Error for an unrecognized slash command; placeholder is the typed command"), locale: .current, String(cmd)))
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only command reference, printed as a system message. The
|
||||
/// suggestion panel hides once arguments are typed, and typos used to
|
||||
/// dead-end in a bare "unknown command" — this is the way out.
|
||||
static var helpText: String {
|
||||
String(localized: "command.help.text", defaultValue: "commands:\n/msg @name [message] — start a private chat\n/who — list who's here\n/clear — clear this chat\n/hug @name — send a hug\n/slap @name — slap with a large trout\n/block @name · /unblock @name\n/fav @name · /unfav @name — favorites (mesh only)\n/group create <name> — start an encrypted group\n/group invite @name · /group remove @name — manage members (creator)\n/group leave · /group list — leave or list your groups\n/ping @name — measure round-trip time (mesh only)\n/trace @name — estimated mesh path (mesh only)\n/pay <token> — send a cashu ecash token in this chat\n/drop <message> — pin a note to this place for 24h (needs location)\n/help — this list", comment: "The /help reference list; command syntax stays verbatim, only the descriptions after each dash are translated")
|
||||
}
|
||||
static let helpText = """
|
||||
commands:
|
||||
/msg @name [message] — start a private chat
|
||||
/who — list who's here
|
||||
/clear — clear this chat
|
||||
/hug @name — send a hug
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
/drop <message> — pin a note to this place for 24h (needs location)
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
/// /drop <text> — a dead drop: pins a note to the current building-level
|
||||
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
|
||||
@ -171,28 +186,28 @@ final class CommandProcessor {
|
||||
/// reads it.
|
||||
private func handleDrop(_ args: String) -> CommandResult {
|
||||
guard LocationNotesSettings.enabled else {
|
||||
return .error(message: String(localized: "command.drop.notes_off", defaultValue: "location notes are off — enable them in the info screen", comment: "Error when /drop is used while location notes are disabled"))
|
||||
return .error(message: "location notes are off — enable them in the info screen")
|
||||
}
|
||||
guard let content = args.trimmedOrNilIfEmpty else {
|
||||
return .error(message: String(localized: "command.drop.usage", defaultValue: "usage: /drop <message>", comment: "Usage hint for /drop"))
|
||||
return .error(message: "usage: /drop <message>")
|
||||
}
|
||||
let location = LocationChannelManager.shared
|
||||
guard location.permissionState == .authorized else {
|
||||
return .error(message: String(localized: "command.drop.needs_location", defaultValue: "leaving a note needs location — enable it in the info screen", comment: "Error when /drop is used without location permission"))
|
||||
return .error(message: "leaving a note needs location — enable it in the info screen")
|
||||
}
|
||||
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
|
||||
location.refreshChannels()
|
||||
return .error(message: String(localized: "command.drop.finding_place", defaultValue: "still finding this place — try again in a moment", comment: "Error when /drop runs before a location fix arrives"))
|
||||
return .error(message: "still finding this place — try again in a moment")
|
||||
}
|
||||
guard let nickname = contextProvider?.nickname,
|
||||
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
|
||||
return .error(message: String(localized: "command.drop.no_relays", defaultValue: "no geo relays reachable — note not left", comment: "Error when /drop finds no reachable geo relays"))
|
||||
return .error(message: "no geo relays reachable — note not left")
|
||||
}
|
||||
// Leaving a note is an explicit notes act: it unlocks the passive
|
||||
// nearby-notes counter (tap-to-reveal) so the sender sees their own
|
||||
// drop counted on the timeline.
|
||||
NearbyNotesCounter.shared.reveal()
|
||||
return .success(message: String(localized: "command.drop.left", defaultValue: "📍 note left here — it fades in 24h", comment: "Confirmation after /drop pins a note"))
|
||||
return .success(message: "📍 note left here — it fades in 24h")
|
||||
}
|
||||
|
||||
// MARK: - Command Handlers
|
||||
@ -200,14 +215,14 @@ final class CommandProcessor {
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard !parts.isEmpty else {
|
||||
return .error(message: String(localized: "command.msg.usage", defaultValue: "usage: /msg @nickname [message]", comment: "Usage hint for /msg"))
|
||||
return .error(message: "usage: /msg @nickname [message]")
|
||||
}
|
||||
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: String(format: String(localized: "command.msg.not_found", defaultValue: "'%@' not found", comment: "Error when /msg can't resolve the nickname"), locale: .current, nickname))
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
}
|
||||
|
||||
contextProvider?.startPrivateChat(with: peerID)
|
||||
@ -217,7 +232,7 @@ final class CommandProcessor {
|
||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: String(format: String(localized: "command.msg.started", defaultValue: "started private chat with %@", comment: "Confirmation after /msg opens a private chat"), locale: .current, nickname))
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
}
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
@ -225,22 +240,22 @@ final class CommandProcessor {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = contextProvider else { return .success(message: String(localized: "command.who.nobody", defaultValue: "nobody around", comment: "Reply to /who when no context is available")) }
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
let names = people.map { $0.displayName }
|
||||
if names.isEmpty { return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online")) }
|
||||
return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, names.sorted().joined(separator: ", ")))
|
||||
if names.isEmpty { return .success(message: "no one else is online right now") }
|
||||
return .success(message: "online: " + names.sorted().joined(separator: ", "))
|
||||
case .mesh:
|
||||
// Mesh context: show connected peer nicknames
|
||||
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
|
||||
return .success(message: String(localized: "command.who.none_online", defaultValue: "no one else is online right now", comment: "Reply to /who when nobody else is online"))
|
||||
return .success(message: "no one else is online right now")
|
||||
}
|
||||
let onlineList = peers.values.sorted().joined(separator: ", ")
|
||||
return .success(message: String(format: String(localized: "command.who.online", defaultValue: "online: %@", comment: "Reply to /who; placeholder is the list of names"), locale: .current, onlineList))
|
||||
return .success(message: "online: \(onlineList)")
|
||||
}
|
||||
}
|
||||
|
||||
@ -256,14 +271,14 @@ final class CommandProcessor {
|
||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command))
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let myNickname = contextProvider?.nickname else {
|
||||
return .error(message: String(format: String(localized: "command.action.not_found", defaultValue: "cannot %1$@ %2$@: not found", comment: "Error when an action command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname))
|
||||
return .error(message: "cannot \(command) \(nickname): not found")
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
@ -330,7 +345,7 @@ final class CommandProcessor {
|
||||
|
||||
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
|
||||
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
|
||||
return .success(message: String(format: String(localized: "command.block.list", defaultValue: "blocked peers: %1$@ | geohash blocks: %2$@", comment: "Reply to /block with no argument; placeholders are the mesh and geohash block lists"), locale: .current, meshList, geoList))
|
||||
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
@ -338,7 +353,7 @@ final class CommandProcessor {
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
// Block the user (mesh/noise identity)
|
||||
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
|
||||
@ -360,24 +375,24 @@ final class CommandProcessor {
|
||||
// Scrub their carried public messages now, while the peerID is
|
||||
// resolvable, so they can't resurface as archived echoes.
|
||||
meshArchive?.purgeArchivedPublicMessages(from: peerID)
|
||||
return .success(message: String(format: String(localized: "command.block.done", defaultValue: "blocked %@. you will no longer see their messages", comment: "Confirmation after blocking a mesh peer"), locale: .current, nickname))
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: true)
|
||||
return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname))
|
||||
return .success(message: "blocked \(nickname) in geohash chats")
|
||||
}
|
||||
|
||||
return .error(message: String(format: String(localized: "command.block.failed", defaultValue: "cannot block %@: not found or unable to verify identity", comment: "Error when /block can't resolve or verify the target"), locale: .current, nickname))
|
||||
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: String(localized: "command.unblock.usage", defaultValue: "usage: /unblock <nickname>", comment: "Usage hint for /unblock"))
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
@ -385,23 +400,23 @@ final class CommandProcessor {
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname))
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: false)
|
||||
return .success(message: String(format: String(localized: "command.unblock.done", defaultValue: "unblocked %@", comment: "Confirmation after unblocking a mesh peer"), locale: .current, nickname))
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
// Try geohash unblock
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname))
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: false)
|
||||
return .success(message: String(format: String(localized: "command.unblock.done_geo", defaultValue: "unblocked %@ in geohash chats", comment: "Confirmation after unblocking a geohash participant"), locale: .current, nickname))
|
||||
return .success(message: "unblocked \(nickname) in geohash chats")
|
||||
}
|
||||
return .error(message: String(format: String(localized: "command.unblock.failed", defaultValue: "cannot unblock %@: not found", comment: "Error when /unblock can't resolve the target"), locale: .current, nickname))
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private static var groupUsage: String { String(localized: "command.group.usage", defaultValue: "usage: /group create <name> · invite @name · remove @name · leave · list", comment: "Usage hint for /group subcommands") }
|
||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
|
||||
private func handleGroup(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
@ -439,12 +454,12 @@ final class CommandProcessor {
|
||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .failed(.error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, command)))
|
||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||
}
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||
return .failed(.error(message: String(format: String(localized: "command.action.not_found_mesh", defaultValue: "cannot %1$@ %2$@: not found on mesh", comment: "Error when a mesh-only command can't resolve its target; placeholders are the command and the nickname"), locale: .current, command, nickname)))
|
||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||
}
|
||||
return .resolved(peerID: peerID, nickname: nickname)
|
||||
}
|
||||
@ -473,7 +488,7 @@ final class CommandProcessor {
|
||||
} ?? ""
|
||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||
}
|
||||
return .success(message: String(format: String(localized: "command.ping.started", defaultValue: "pinging %@…", comment: "Confirmation that /ping sent a probe"), locale: .current, nickname))
|
||||
return .success(message: "pinging \(nickname)…")
|
||||
}
|
||||
|
||||
private func handleTrace(_ args: String) -> CommandResult {
|
||||
@ -485,20 +500,16 @@ final class CommandProcessor {
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: String(format: String(localized: "command.trace.no_path", defaultValue: "no known path to %@", comment: "Reply when /trace has no mesh path to the target"), locale: .current, target.nickname))
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
// present it as an estimate.
|
||||
let hopNames = intermediates.map { hop in
|
||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||
}
|
||||
let you = String(localized: "command.trace.you", defaultValue: "you", comment: "Label for the local device at the start of a /trace path")
|
||||
let chain = ([you] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let hops = intermediates.count + 1
|
||||
let pathMessage = hops == 1
|
||||
? String(format: String(localized: "command.trace.path_one", defaultValue: "estimated path: %@ (1 hop)", comment: "Reply to /trace for a single-hop path; placeholder is the node chain"), locale: .current, chain)
|
||||
: String(format: String(localized: "command.trace.path_many", defaultValue: "estimated path: %1$@ (%2$lld hops)", comment: "Reply to /trace; placeholders are the node chain and hop count"), locale: .current, chain, hops)
|
||||
return .success(message: pathMessage)
|
||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||
}
|
||||
|
||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||
@ -509,44 +520,44 @@ final class CommandProcessor {
|
||||
private func handlePay(_ args: String) -> CommandResult {
|
||||
var parts = args.trimmed.split(separator: " ").map(String.init)
|
||||
guard !parts.isEmpty else {
|
||||
return .success(message: String(localized: "command.pay.usage", defaultValue: "usage: /pay <token> — paste a cashu token: /pay cashuA…", comment: "Usage hint for /pay"))
|
||||
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
||||
}
|
||||
|
||||
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
||||
if confirmedPublic { parts.removeLast() }
|
||||
|
||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
||||
return .error(message: String(localized: "command.pay.not_token", defaultValue: "that doesn't look like a cashu token — expected cashuA… or cashuB…", comment: "Error when /pay input has no cashu prefix"))
|
||||
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||
}
|
||||
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||
return .error(message: String(localized: "command.pay.invalid", defaultValue: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it", comment: "Error when /pay input fails to decode"))
|
||||
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
||||
}
|
||||
|
||||
let summary = info.displayAmount ?? "a cashu token"
|
||||
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.sendPrivateMessage(token, to: peerID)
|
||||
return .success(message: String(format: String(localized: "command.pay.sent_private", defaultValue: "sent %@ — cashu is a bearer token; whoever redeems it first gets the funds", comment: "Confirmation after sending a cashu token in a private chat; placeholder is the amount summary"), locale: .current, summary))
|
||||
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
||||
}
|
||||
|
||||
guard confirmedPublic else {
|
||||
return .error(message: String(localized: "command.pay.public_confirm", defaultValue: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public", comment: "Confirmation gate before sending a cashu token to a public channel"))
|
||||
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
||||
}
|
||||
|
||||
contextProvider?.sendPublicMessage(token)
|
||||
return .success(message: String(format: String(localized: "command.pay.sent_public", defaultValue: "sent %@ to the public channel — anyone here can redeem it", comment: "Confirmation after sending a cashu token to a public channel; placeholder is the amount summary"), locale: .current, summary))
|
||||
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: String(format: String(localized: "command.action.usage", defaultValue: "usage: /%@ <nickname>", comment: "Usage hint for a command that takes a nickname; placeholder is the command name"), locale: .current, (add ? "fav" : "unfav")))
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: String(format: String(localized: "command.fav.not_found", defaultValue: "can't find peer: %@", comment: "Error when /fav or /unfav can't resolve the nickname"), locale: .current, nickname))
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
@ -560,17 +571,13 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add
|
||||
? String(format: String(localized: "command.fav.already", defaultValue: "%@ is already a favorite", comment: "Reply when /fav targets an existing favorite"), locale: .current, nickname)
|
||||
: String(format: String(localized: "command.fav.not_favorite", defaultValue: "%@ is not a favorite", comment: "Reply when /unfav targets someone who isn't a favorite"), locale: .current, nickname))
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add
|
||||
? String(format: String(localized: "command.fav.added", defaultValue: "added %@ to favorites", comment: "Confirmation after /fav"), locale: .current, nickname)
|
||||
: String(format: String(localized: "command.fav.removed", defaultValue: "removed %@ from favorites", comment: "Confirmation after /unfav"), locale: .current, nickname))
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -260,10 +260,8 @@ final class NotificationService {
|
||||
}
|
||||
|
||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||
let title = String(localized: "notification.nearby.title", defaultValue: "👥 bitchatters nearby!", comment: "Title of the local notification when mesh peers come into range")
|
||||
let body = peerCount == 1
|
||||
? String(localized: "notification.nearby.body_one", defaultValue: "1 person around", comment: "Body of the nearby notification for exactly one peer")
|
||||
: String(format: String(localized: "notification.nearby.body_many", defaultValue: "%lld people around", comment: "Body of the nearby notification; placeholder is the peer count"), locale: .current, peerCount)
|
||||
let title = "👥 bitchatters nearby!"
|
||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||
let identifier = "network-available"
|
||||
|
||||
|
||||
@ -216,10 +216,6 @@ 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
|
||||
|
||||
@ -195,8 +195,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
nickname: peerInfo.nickname,
|
||||
lastSeen: peerInfo.lastSeen,
|
||||
isConnected: peerInfo.isConnected,
|
||||
isReachable: isReachable,
|
||||
localPetname: localPetname(forFingerprint: fingerprint)
|
||||
isReachable: isReachable
|
||||
)
|
||||
|
||||
// Check for favorite status
|
||||
@ -219,8 +218,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
nickname: favorite.peerNickname,
|
||||
lastSeen: favorite.lastUpdated,
|
||||
isConnected: false,
|
||||
isReachable: false,
|
||||
localPetname: localPetname(forFingerprint: favorite.peerNoisePublicKey.sha256Fingerprint())
|
||||
isReachable: false
|
||||
)
|
||||
|
||||
peer.favoriteStatus = favorite
|
||||
@ -229,21 +227,6 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
return peer
|
||||
}
|
||||
|
||||
/// Rebuild peer rows after a social-identity write (local alias, etc.) so
|
||||
/// display names update without waiting for a mesh event.
|
||||
func refreshPeers() {
|
||||
updatePeers()
|
||||
}
|
||||
|
||||
private func localPetname(forFingerprint fingerprint: String?) -> String? {
|
||||
guard let fingerprint,
|
||||
let petname = identityManager.getSocialIdentity(for: fingerprint)?.localPetname,
|
||||
!petname.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
return petname
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get peer by ID
|
||||
|
||||
@ -84,8 +84,7 @@ final class VerificationService {
|
||||
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess else { return nil }
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
@ -106,10 +105,9 @@ 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, in both directions: a future-dated timestamp must not
|
||||
// buy a QR a longer validity window than a fresh one gets.
|
||||
// Freshness
|
||||
let now = Date().timeIntervalSince1970
|
||||
if abs(now - Double(qr.ts)) > maxAge { return nil }
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let transport = transport else { return nil }
|
||||
|
||||
@ -57,11 +57,6 @@ struct SyncTypeFlags: OptionSet {
|
||||
// Live voice is only useful now; replaying stale audio frames via
|
||||
// sync would waste airtime (receivers drop them as stale anyway).
|
||||
case .voiceFrame: return nil
|
||||
// Rotating-ID presence is valid only inside its epoch, and gossiping it
|
||||
// would defeat the point: a synced announce would let a device that was
|
||||
// never in radio range collect tag blocks, turning a local presence
|
||||
// beacon into a network-wide one.
|
||||
case .announceV2: return nil
|
||||
// Prekey bundles gossip like board posts. The bitfield is a
|
||||
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
|
||||
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
|
||||
|
||||
@ -1,113 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// The one-tap "quick join" suggestion in the channels sheet: the
|
||||
/// region-level geohash channel around the device region's main population
|
||||
/// center. Derived from the device locale — no location access, no GPS; the
|
||||
/// tap reuses the same path as typing the geohash and teleporting.
|
||||
///
|
||||
/// This replaced an earlier curated list of heavily censored countries. A
|
||||
/// hand-picked roster invites disputes over who is on it and goes stale
|
||||
/// with every political shift; deriving the suggestion from the locale
|
||||
/// gives every country the same treatment under one rule.
|
||||
///
|
||||
/// Quick join is channel discovery, not protection: region cells are
|
||||
/// public, well known, and trivially enumerable, so the suggested channel
|
||||
/// must be assumed watched. Joining it hides nothing and bypasses nothing.
|
||||
struct QuickJoinSuggestion {
|
||||
let regionCode: String
|
||||
let geohash: String
|
||||
|
||||
/// Regional-indicator flag emoji derived from the ISO code.
|
||||
var flag: String {
|
||||
regionCode.unicodeScalars.reduce(into: "") { result, scalar in
|
||||
if let indicator = Unicode.Scalar(127397 + scalar.value) {
|
||||
result.unicodeScalars.append(indicator)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localizedName: String {
|
||||
Locale.current.localizedString(forRegionCode: regionCode) ?? regionCode
|
||||
}
|
||||
|
||||
/// The suggestion for the device's region, or nil when the region is
|
||||
/// unknown or unmapped (the section is hidden then).
|
||||
static func current(for locale: Locale = .current) -> QuickJoinSuggestion? {
|
||||
guard let code = locale.region?.identifier.uppercased(),
|
||||
let geohash = regionCells[code] else { return nil }
|
||||
return QuickJoinSuggestion(regionCode: code, geohash: geohash)
|
||||
}
|
||||
|
||||
/// ISO 3166-1 alpha-2 region → the 2-character geohash cell over the
|
||||
/// country's main population center — the largest metro, not always the
|
||||
/// capital (US → New York, TR → Istanbul, MM → Yangon), because that is
|
||||
/// the cell where a country's channel actually forms. Someone elsewhere
|
||||
/// in the country lands in this cell too; the caption in the sheet says
|
||||
/// so rather than calling it "your country's channel".
|
||||
///
|
||||
/// Coverage is every UN member state plus inhabited territories a
|
||||
/// device locale plausibly reports; a missing code just hides the row.
|
||||
/// Cells are 11.25° × 5.625°, so city-level coordinates are ample. The
|
||||
/// table is generated by geohashing each center's coordinates; the
|
||||
/// twelve entries the earlier roster shipped were independently
|
||||
/// verified in review and reproduce unchanged, and entries near a cell
|
||||
/// boundary were checked by hand. Distinct countries can legitimately
|
||||
/// share a cell (Seoul and Pyongyang are both "wy") — cells are big.
|
||||
private static let regionCells: [String: String] = [
|
||||
"AD": "sp", "AE": "th", "AF": "tw", "AG": "de",
|
||||
"AL": "sr", "AM": "sz", "AO": "kq", "AR": "69",
|
||||
"AT": "u2", "AU": "r3", "AW": "d6", "AZ": "tp",
|
||||
"BA": "sr", "BB": "dd", "BD": "wh", "BE": "u1",
|
||||
"BF": "ef", "BG": "sx", "BH": "th", "BI": "kx",
|
||||
"BJ": "s1", "BM": "dt", "BN": "w8", "BO": "6s",
|
||||
"BR": "6g", "BS": "dk", "BT": "tu", "BW": "ke",
|
||||
"BY": "u9", "BZ": "d5", "CA": "dp", "CD": "kr",
|
||||
"CF": "s2", "CG": "kr", "CH": "u0", "CI": "eb",
|
||||
"CL": "66", "CM": "s0", "CN": "wx", "CO": "d2",
|
||||
"CR": "d1", "CU": "dh", "CV": "e6", "CW": "d6",
|
||||
"CY": "sw", "CZ": "u2", "DE": "u3", "DJ": "sf",
|
||||
"DK": "u3", "DM": "dd", "DO": "d7", "DZ": "sn",
|
||||
"EC": "6p", "EE": "ud", "EG": "st", "ER": "sf",
|
||||
"ES": "ez", "ET": "sc", "FI": "ud", "FJ": "ru",
|
||||
"FM": "x9", "FO": "gg", "FR": "u0", "GA": "s0",
|
||||
"GB": "gc", "GD": "dd", "GE": "sz", "GF": "db",
|
||||
"GG": "gb", "GH": "eb", "GI": "ey", "GL": "fg",
|
||||
"GM": "ed", "GN": "e9", "GP": "dd", "GQ": "s0",
|
||||
"GR": "sw", "GT": "9f", "GU": "x4", "GW": "ed",
|
||||
"GY": "d9", "HK": "we", "HN": "d4", "HR": "u2",
|
||||
"HT": "d7", "HU": "u2", "ID": "qq", "IE": "gc",
|
||||
"IL": "sv", "IM": "gc", "IN": "tt", "IQ": "sv",
|
||||
"IR": "tn", "IS": "ge", "IT": "sr", "JE": "gb",
|
||||
"JM": "d7", "JO": "sv", "JP": "xn", "KE": "kz",
|
||||
"KG": "tx", "KH": "w6", "KI": "xb", "KM": "kv",
|
||||
"KN": "de", "KP": "wy", "KR": "wy", "KW": "tj",
|
||||
"KY": "d5", "KZ": "tx", "LA": "w7", "LB": "sy",
|
||||
"LC": "dd", "LI": "u0", "LK": "tc", "LR": "ec",
|
||||
"LS": "kd", "LT": "u9", "LU": "u0", "LV": "ud",
|
||||
"LY": "sm", "MA": "ev", "MC": "sp", "MD": "u8",
|
||||
"ME": "sr", "MG": "mh", "MH": "xc", "MK": "sr",
|
||||
"ML": "ef", "MM": "w4", "MN": "y2", "MO": "we",
|
||||
"MQ": "dd", "MR": "ee", "MT": "sq", "MU": "mk",
|
||||
"MV": "t8", "MW": "kv", "MX": "9g", "MY": "w2",
|
||||
"MZ": "ke", "NA": "k7", "NC": "rs", "NE": "s4",
|
||||
"NG": "s1", "NI": "d4", "NL": "u1", "NO": "u4",
|
||||
"NP": "tu", "NR": "rx", "NZ": "rc", "OM": "tk",
|
||||
"PA": "d1", "PE": "6m", "PF": "2s", "PG": "rq",
|
||||
"PH": "wd", "PK": "tk", "PL": "u3", "PR": "de",
|
||||
"PS": "sv", "PT": "ey", "PW": "wc", "PY": "6e",
|
||||
"QA": "th", "RE": "mh", "RO": "sx", "RS": "sr",
|
||||
"RU": "uc", "RW": "kx", "SA": "th", "SB": "rw",
|
||||
"SC": "mp", "SD": "sd", "SE": "u6", "SG": "w2",
|
||||
"SI": "u2", "SK": "u2", "SL": "e9", "SM": "sr",
|
||||
"SN": "ed", "SO": "t0", "SR": "dc", "SS": "s8",
|
||||
"ST": "s0", "SV": "d4", "SY": "sv", "SZ": "ke",
|
||||
"TD": "s6", "TG": "s1", "TH": "w4", "TJ": "tw",
|
||||
"TL": "qy", "TM": "tq", "TN": "sn", "TO": "2h",
|
||||
"TR": "sx", "TT": "d9", "TV": "ry", "TW": "ws",
|
||||
"TZ": "ky", "UA": "u8", "UG": "s8", "US": "dr",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -14,22 +14,17 @@ import AppKit
|
||||
|
||||
enum SystemSettings {
|
||||
case bluetooth
|
||||
/// The radio power toggle, distinct from the `.bluetooth` privacy
|
||||
/// permission anchor: an already-authorized person whose radio is
|
||||
/// switched off can't fix anything from the privacy pane.
|
||||
case bluetoothPower
|
||||
case location
|
||||
case microphone
|
||||
|
||||
#if os(macOS)
|
||||
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
|
||||
|
||||
private var macURLString: String {
|
||||
private var macPrivacyAnchor: String {
|
||||
switch self {
|
||||
case .bluetooth: "\(Self.baseURL)?Privacy_Bluetooth"
|
||||
case .bluetoothPower: "x-apple.systempreferences:com.apple.BluetoothSettings"
|
||||
case .location: "\(Self.baseURL)?Privacy_LocationServices"
|
||||
case .microphone: "\(Self.baseURL)?Privacy_Microphone"
|
||||
case .bluetooth: "Privacy_Bluetooth"
|
||||
case .location: "Privacy_LocationServices"
|
||||
case .microphone: "Privacy_Microphone"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@ -40,7 +35,8 @@ enum SystemSettings {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let url = URL(string: macURLString) {
|
||||
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
|
||||
if let url = URL(string: urlString) {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
#endif
|
||||
|
||||
@ -40,6 +40,7 @@ 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?
|
||||
@ -54,10 +55,13 @@ 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.
|
||||
@ -76,10 +80,10 @@ extension ChatViewModel: ChatLifecycleContext {
|
||||
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
|
||||
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
|
||||
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
|
||||
// `markPrivateChatRead(_:)`,
|
||||
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
|
||||
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
|
||||
// the routing/ack members,
|
||||
// `deriveNostrIdentity(forGeohash:)`,
|
||||
// the routing/ack members, `isTeleported`,
|
||||
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
|
||||
// and `favoriteRelationship(forNoiseKey:)`
|
||||
// are shared requirements with the other contexts or satisfied by
|
||||
// existing `ChatViewModel` members. The members below flatten nested
|
||||
@ -139,21 +143,34 @@ 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 *"
|
||||
// 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) {
|
||||
|
||||
if let peerID = context.selectedPrivateChatPeer {
|
||||
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() {
|
||||
@ -276,10 +293,8 @@ final class ChatLifecycleCoordinator {
|
||||
}
|
||||
|
||||
private extension ChatLifecycleCoordinator {
|
||||
/// 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 }
|
||||
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
|
||||
guard let peerNickname = context.peerNickname(for: peerID) else { return }
|
||||
|
||||
let sessionState = context.noiseSessionState(for: peerID)
|
||||
switch sessionState {
|
||||
@ -290,21 +305,19 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
func appendPrivateScreenshotNotice(for peerID: PeerID) {
|
||||
let notice = BitchatMessage(
|
||||
sender: "system",
|
||||
content: String(localized: "system.screenshot.you", defaultValue: "you took a screenshot", comment: "Local system line after your screenshot notice was sent to the DM peer"),
|
||||
content: "you took a screenshot",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@ -316,6 +329,37 @@ 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
|
||||
|
||||
@ -551,7 +551,7 @@ final class ChatMediaTransferCoordinator {
|
||||
guard context.canSendMediaInCurrentContext else {
|
||||
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
context.addSystemMessage(String(localized: "Voice notes are only available in mesh chats.", comment: "System message when a voice note is attempted outside mesh chats"))
|
||||
context.addSystemMessage("Voice notes are only available in mesh chats.")
|
||||
return
|
||||
}
|
||||
|
||||
@ -694,7 +694,7 @@ final class ChatMediaTransferCoordinator {
|
||||
guard context.canSendMediaInCurrentContext else {
|
||||
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
||||
cleanup?()
|
||||
context.addSystemMessage(String(localized: "Images are only available in mesh chats.", comment: "System message when an image send is attempted outside mesh chats"))
|
||||
context.addSystemMessage("Images are only available in mesh chats.")
|
||||
return
|
||||
}
|
||||
|
||||
@ -1859,7 +1859,8 @@ 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,10 +68,7 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
// 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 {
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
@ -391,7 +388,7 @@ final class ChatMessageFormatter {
|
||||
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||
}
|
||||
if isVerifiedSender, !message.isPrivate {
|
||||
if isVerifiedSender {
|
||||
appendVerifiedSeal(to: &result, baseColor: baseColor, design: design)
|
||||
}
|
||||
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||
|
||||
@ -440,13 +440,7 @@ final class ChatPeerIdentityCoordinator {
|
||||
return cachedStatus
|
||||
}
|
||||
|
||||
// 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 hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
||||
let sessionState = context.noiseSessionState(for: peerID)
|
||||
|
||||
let status: EncryptionStatus
|
||||
@ -454,11 +448,11 @@ final class ChatPeerIdentityCoordinator {
|
||||
case .established:
|
||||
status = verifiedEncryptionStatus(for: peerID)
|
||||
case .handshaking, .handshakeQueued:
|
||||
status = .noiseHandshaking
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
|
||||
case .none:
|
||||
status = .noHandshake
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
|
||||
case .failed:
|
||||
status = .none
|
||||
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
|
||||
}
|
||||
|
||||
context.setCachedEncryptionStatus(status, for: peerID)
|
||||
@ -483,21 +477,15 @@ final class ChatPeerIdentityCoordinator {
|
||||
return peerID.id
|
||||
}
|
||||
|
||||
// Local aliases outrank announced nicknames so a saved petname is
|
||||
// actually visible after the fingerprint sheet dismisses.
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
let identity = context.socialIdentity(forFingerprint: fingerprint),
|
||||
let petname = identity.localPetname,
|
||||
!petname.isEmpty {
|
||||
return petname
|
||||
}
|
||||
|
||||
if let nickname = context.meshPeerNicknames()[peerID] {
|
||||
return nickname
|
||||
}
|
||||
|
||||
if let fingerprint = getFingerprint(for: peerID),
|
||||
let identity = context.socialIdentity(forFingerprint: fingerprint) {
|
||||
if let petname = identity.localPetname {
|
||||
return petname
|
||||
}
|
||||
return identity.claimedNickname
|
||||
}
|
||||
|
||||
|
||||
@ -294,8 +294,7 @@ final class ChatVerificationCoordinator {
|
||||
}
|
||||
|
||||
var nonce = Data(count: 16)
|
||||
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess else { return false }
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
var pending = PendingVerification(
|
||||
noiseKeyHex: qr.noiseKeyHex,
|
||||
signKeyHex: qr.signKeyHex,
|
||||
|
||||
@ -367,10 +367,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
var torRestartPending: Bool = false
|
||||
// Announce a stalled bootstrap once per attempt, not once per poll.
|
||||
var torStallAnnounced: Bool = false
|
||||
// Live "tor is blocked" state for the connectivity banner. The system
|
||||
// message above only reaches geohash timelines; this is the chrome-level
|
||||
// signal that stays up until tor actually gets through.
|
||||
@Published var torBlocked: Bool = false
|
||||
// Ensure we set up DM subscription only once per app session
|
||||
var nostrHandlersSetup: Bool = false
|
||||
var geoChannelCoordinator: GeoChannelCoordinator?
|
||||
@ -1775,13 +1771,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
panicNetworkLifecycle.restart()
|
||||
}
|
||||
|
||||
// In a duress scenario "did it work?" must not be a guess — the
|
||||
// natural response to uncertainty is to trigger the wipe again.
|
||||
// The failure case surfaces separately via `panicRecoveryBlocked`.
|
||||
addMeshOnlySystemMessage(
|
||||
String(localized: "system.panic.completed", defaultValue: "all data wiped — new identity created", comment: "System message confirming a successful panic wipe")
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@ -40,7 +40,6 @@ extension ChatViewModel {
|
||||
@objc func handleTorDidBecomeReady() {
|
||||
Task { @MainActor in
|
||||
self.torStallAnnounced = false
|
||||
self.torBlocked = false
|
||||
// Only announce "restarted" if we actually restarted this session
|
||||
if self.torRestartPending {
|
||||
// Post only in geohash channels (queue if not active)
|
||||
@ -69,7 +68,6 @@ extension ChatViewModel {
|
||||
// runtime preference is what says whether anyone is waiting on
|
||||
// Tor. Turning Tor off mid-bootstrap must not read as blocking.
|
||||
guard NetworkActivationService.persistedTorPreference() else { return }
|
||||
self.torBlocked = true
|
||||
guard !self.torStallAnnounced else { return }
|
||||
self.torStallAnnounced = true
|
||||
self.addGeohashOnlySystemMessage(
|
||||
@ -88,9 +86,6 @@ extension ChatViewModel {
|
||||
self.torInitialReadyAnnounced = false
|
||||
self.torRestartPending = false
|
||||
self.torStallAnnounced = false
|
||||
// Turning tor off means nobody is waiting on it; turning it on
|
||||
// starts a fresh attempt. Either way the stall banner resets.
|
||||
self.torBlocked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -357,13 +357,6 @@ 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))...",
|
||||
@ -452,11 +445,6 @@ 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
|
||||
}
|
||||
@ -554,22 +542,6 @@ 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? {
|
||||
|
||||
@ -29,7 +29,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
var alertMessage: String {
|
||||
switch self {
|
||||
case .error(let message): message
|
||||
case .permissionDenied: String(localized: "voice.error.mic_permission", defaultValue: "microphone access is required to record voice notes.", comment: "Alert message when the microphone permission is denied")
|
||||
case .permissionDenied: "Microphone access is required to record voice notes."
|
||||
case .idle, .requestingPermission, .preparing, .recording: ""
|
||||
}
|
||||
}
|
||||
@ -156,7 +156,7 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
activeSession = nil
|
||||
state = .error(message: String(localized: "voice.error.start_failed", defaultValue: "could not start recording.", comment: "Alert message when the recorder fails to start"))
|
||||
state = .error(message: "Could not start recording.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -202,8 +202,8 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
guard state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save")
|
||||
: String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved")
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
return
|
||||
}
|
||||
@ -212,8 +212,8 @@ final class VoiceRecordingViewModel: ObservableObject {
|
||||
guard generation == holdGeneration, state == .idle else { return }
|
||||
state = .error(
|
||||
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||
? String(localized: "voice.error.too_short", defaultValue: "recording is too short.", comment: "Alert message when a voice note is released too quickly to save")
|
||||
: String(localized: "voice.error.save_failed", defaultValue: "recording failed to save.", comment: "Alert message when a finished voice note cannot be saved")
|
||||
? "Recording is too short."
|
||||
: "Recording failed to save."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
//
|
||||
// ConnectivityStatusBanner.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CoreBluetooth
|
||||
import SwiftUI
|
||||
|
||||
/// The degraded state a banner should surface, in priority order. Bluetooth
|
||||
/// outranks tor: without the radio the app's core promise (mesh) is dead,
|
||||
/// while a tor stall only pauses internet features.
|
||||
enum ConnectivityIssue: Equatable {
|
||||
case bluetoothOff
|
||||
case bluetoothDenied
|
||||
case bluetoothUnsupported
|
||||
case torBlocked
|
||||
|
||||
/// Pure resolution so the banner's priority logic is testable without a
|
||||
/// view. `.unknown`/`.resetting` deliberately resolve to nil — the radio
|
||||
/// is still starting and a flash of "bluetooth is off" at launch would
|
||||
/// be the same false signal this banner exists to prevent.
|
||||
static func resolve(bluetoothState: CBManagerState, torBlocked: Bool) -> ConnectivityIssue? {
|
||||
switch bluetoothState {
|
||||
case .poweredOff: return .bluetoothOff
|
||||
case .unauthorized: return .bluetoothDenied
|
||||
case .unsupported: return .bluetoothUnsupported
|
||||
case .poweredOn, .unknown, .resetting: break
|
||||
@unknown default: break
|
||||
}
|
||||
return torBlocked ? .torBlocked : nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Persistent one-line banner under the header while connectivity is
|
||||
/// degraded. The Bluetooth alert is a one-shot modal — once dismissed, the
|
||||
/// app used to look completely normal (empty timeline, radar sweeping)
|
||||
/// while the radio was off. Tor stalls were only ever announced inside
|
||||
/// geohash timelines. This banner is the always-visible truth; it renders
|
||||
/// nothing when everything is fine.
|
||||
struct ConnectivityStatusBanner: View {
|
||||
let issue: ConnectivityIssue
|
||||
@ThemedPalette private var palette
|
||||
|
||||
private var message: String {
|
||||
switch issue {
|
||||
case .bluetoothOff:
|
||||
return String(
|
||||
localized: "content.banner.bluetooth_off",
|
||||
defaultValue: "bluetooth is off — nobody nearby can reach you. tap to fix.",
|
||||
comment: "Persistent banner while Bluetooth is switched off; tapping opens system settings"
|
||||
)
|
||||
case .bluetoothDenied:
|
||||
return String(
|
||||
localized: "content.banner.bluetooth_denied",
|
||||
defaultValue: "bluetooth access denied — mesh is offline. tap to open settings.",
|
||||
comment: "Persistent banner while Bluetooth permission is denied; tapping opens system settings"
|
||||
)
|
||||
case .bluetoothUnsupported:
|
||||
return String(
|
||||
localized: "content.banner.bluetooth_unsupported",
|
||||
defaultValue: "no bluetooth on this device — mesh is unavailable. location channels still work over the internet.",
|
||||
comment: "Persistent banner on devices without Bluetooth support"
|
||||
)
|
||||
case .torBlocked:
|
||||
return String(
|
||||
localized: "content.banner.tor_blocked",
|
||||
defaultValue: "tor can't connect — internet features are paused. mesh still works.",
|
||||
comment: "Persistent banner while Tor bootstrap has stalled, likely because the network blocks it"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bluetooth problems deep-link to where the fix actually lives —
|
||||
/// powered-off goes to the radio controls, denied to the privacy
|
||||
/// permission pane. The tor stall has no in-app remedy (the network is
|
||||
/// blocking it), so that banner is inert.
|
||||
private var settingsDestination: SystemSettings? {
|
||||
switch issue {
|
||||
case .bluetoothOff: return .bluetoothPower
|
||||
case .bluetoothDenied: return .bluetooth
|
||||
case .bluetoothUnsupported, .torBlocked: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let destination = settingsDestination {
|
||||
Button(action: { destination.open() }) {
|
||||
label
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var label: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: issue == .torBlocked ? "network.slash" : "antenna.radiowaves.left.and.right.slash")
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
Text(message)
|
||||
.bitchatFont(size: 11)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.foregroundColor(palette.alertRed)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.12))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@ -19,10 +19,6 @@ struct MeshEmptyStateView: View {
|
||||
/// intro/help narration (the timeline isn't empty) and shrinks the
|
||||
/// radar, keeping the sightings tally and the live hints visible.
|
||||
var compact: Bool = false
|
||||
/// Whether the radio can actually scan. With Bluetooth off or denied the
|
||||
/// sweep must not run — an animated "searching" over a dead radio is an
|
||||
/// actively false status display. Defaults to true for previews.
|
||||
var bluetoothAvailable: Bool = true
|
||||
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@ -73,12 +69,8 @@ struct MeshEmptyStateView: View {
|
||||
|
||||
/// The radar means "searching for people": once anyone is connected or
|
||||
/// reachable on the mesh, the search is over and the sweep goes away.
|
||||
/// And it only means that while the radio is actually on — the
|
||||
/// connectivity banner carries the message when it isn't.
|
||||
private var isSearchingForPeers: Bool {
|
||||
bluetoothAvailable
|
||||
&& peerListModel.connectedMeshPeerCount == 0
|
||||
&& peerListModel.reachableMeshPeerCount == 0
|
||||
peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
//
|
||||
// PanicWipeBlockedBanner.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Shown under the header while a panic wipe has not committed
|
||||
/// (`ChatViewModel.panicRecoveryBlocked`). A wipe that fails must be as loud
|
||||
/// as the wipe itself: the person who triggered it may be relying on the
|
||||
/// device being clean, and networking stays disabled until a relaunch retries
|
||||
/// the transaction — without this banner the app just looks silently dead.
|
||||
struct PanicWipeBlockedBanner: View {
|
||||
@ThemedPalette private var palette
|
||||
|
||||
private var message: String {
|
||||
String(
|
||||
localized: "content.banner.panic_blocked",
|
||||
defaultValue: "wipe incomplete — some data may remain. quit and reopen bitchat to retry.",
|
||||
comment: "Banner shown when a panic wipe did not fully commit; relaunching the app retries the wipe"
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.bitchatFont(size: 11, weight: .semibold)
|
||||
Text(message)
|
||||
.bitchatFont(size: 11)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.foregroundColor(palette.alertRed)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.12))
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
@ -50,15 +50,6 @@ 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,9 +2,6 @@ import SwiftUI
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@ -32,7 +29,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).enumerated()), id: \.element) { index, suggestion in
|
||||
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
|
||||
Button(action: {
|
||||
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
|
||||
}) {
|
||||
@ -46,11 +43,6 @@ 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)
|
||||
}
|
||||
@ -81,28 +73,7 @@ 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.
|
||||
@ -238,7 +209,7 @@ private extension ContentComposerView {
|
||||
}
|
||||
Spacer()
|
||||
Button(action: voiceRecordingVM.cancel) {
|
||||
Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle")
|
||||
Label("Cancel", systemImage: "xmark.circle")
|
||||
.labelStyle(.iconOnly)
|
||||
.font(.bitchatSystem(size: 18))
|
||||
.foregroundColor(.red)
|
||||
@ -403,104 +374,3 @@ 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
|
||||
}
|
||||
|
||||
@ -30,10 +30,6 @@ struct ContentHeaderView: View {
|
||||
/// timeline is showing) — they should light the pin too.
|
||||
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
|
||||
|
||||
@State private var pendingShareGeohash: String?
|
||||
@State private var showSharePrecisionWarning = false
|
||||
@State private var activeSharePayload: ChannelSharePayload?
|
||||
|
||||
/// The bridged-people count belongs to the mesh channel only.
|
||||
private var showBridgedPeerCount: Bool {
|
||||
if case .location = locationChannelsModel.selectedChannel { return false }
|
||||
@ -51,20 +47,11 @@ struct ContentHeaderView: View {
|
||||
// cluster at priority 3 never gives up width.
|
||||
.layoutPriority(2)
|
||||
.onTapGesture(count: 3) {
|
||||
// Confirm before destroying: the same logo is the single-tap
|
||||
// App Info entry point and sits beside the nickname field, so
|
||||
// a fumbled tap must never be able to wipe the device. The
|
||||
// dialog matches the Settings-pane panic button; under duress
|
||||
// it costs one extra tap.
|
||||
appChromeModel.requestPanicWipe()
|
||||
appChromeModel.panicClearAllData()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
// The confirmation dialog itself is hosted on ContentView
|
||||
// (next to the failed-wipe banner), not on this Text: a host
|
||||
// that can be covered or removed could take the pending
|
||||
// dialog down with it.
|
||||
// This is the only entry point to App Info, but it reads as
|
||||
// static text; surface the tap. (The triple-tap panic wipe
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
@ -226,16 +213,6 @@ struct ContentHeaderView: View {
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
|
||||
Button(action: { requestHeaderShare(forGeohash: channel.geohash) }) {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Accessibility label for sharing the active location channel")
|
||||
)
|
||||
}
|
||||
|
||||
Button(action: { appChromeModel.isLocationChannelsSheetPresented = true }) {
|
||||
@ -359,37 +336,8 @@ struct ContentHeaderView: View {
|
||||
} message: {
|
||||
Text("content.alert.screenshot.message")
|
||||
}
|
||||
.confirmationDialog(
|
||||
String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite"),
|
||||
isPresented: $showSharePrecisionWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")) {
|
||||
if let gh = pendingShareGeohash {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: gh))
|
||||
}
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
} message: {
|
||||
Text(String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite"))
|
||||
}
|
||||
.sheet(item: $activeSharePayload) { payload in
|
||||
ShareActivityView(text: payload.text)
|
||||
}
|
||||
.themedChromePanel(edge: .top)
|
||||
}
|
||||
|
||||
private func requestHeaderShare(forGeohash geohash: String) {
|
||||
if ChannelShare.shouldWarn(forGeohash: geohash) {
|
||||
pendingShareGeohash = geohash
|
||||
showSharePrecisionWarning = true
|
||||
} else {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
|
||||
@ -183,18 +183,6 @@ struct ContentPeopleSheetView: View {
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
// This sheet covers the root header where the connectivity
|
||||
// banner lives; a person sitting in the people list or a DM
|
||||
// would otherwise get no persistent signal that the radio is
|
||||
// off or tor is stalled. Mirror it here.
|
||||
.safeAreaInset(edge: .top, spacing: 0) {
|
||||
if let issue = ConnectivityIssue.resolve(
|
||||
bluetoothState: appChromeModel.bluetoothState,
|
||||
torBlocked: appChromeModel.torBlocked
|
||||
) {
|
||||
ConnectivityStatusBanner(issue: issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.themedSheetBackground()
|
||||
.foregroundColor(palette.primary)
|
||||
@ -282,7 +270,7 @@ struct ContentPeopleSheetView: View {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: voiceAlertBinding, actions: {
|
||||
.alert("Recording Error", isPresented: voiceAlertBinding, actions: {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
if voiceRecordingVM.state == .permissionDenied {
|
||||
Button("location_channels.action.open_settings") {
|
||||
@ -297,10 +285,7 @@ struct ContentPeopleSheetView: View {
|
||||
isPresented: bluetoothAlertBinding
|
||||
) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
// Powered-off needs the radio controls, not the privacy pane.
|
||||
(appChromeModel.bluetoothState == .poweredOff
|
||||
? SystemSettings.bluetoothPower
|
||||
: SystemSettings.bluetooth).open()
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
@ -380,16 +365,6 @@ 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",
|
||||
@ -425,16 +400,6 @@ 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)
|
||||
@ -650,9 +615,7 @@ 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 — or
|
||||
// when the peer is reachable only over Nostr, where delivery is
|
||||
// gift-wrapped end-to-end without a Noise session.
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let noiseSecured: Bool = {
|
||||
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||
@ -660,8 +623,7 @@ private struct ContentPrivateChatSheetView: View {
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable
|
||||
if isGeoDM || noiseSecured || nostrTransport {
|
||||
if isGeoDM || noiseSecured {
|
||||
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")
|
||||
|
||||
@ -397,7 +397,7 @@ struct ContentView: View {
|
||||
ImagePreviewView(url: url)
|
||||
}
|
||||
}
|
||||
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: rootVoiceAlertBinding, actions: {
|
||||
.alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: {
|
||||
Button("common.ok", role: .cancel) {}
|
||||
if voiceRecordingVM.state == .permissionDenied {
|
||||
Button("location_channels.action.open_settings") {
|
||||
@ -409,10 +409,7 @@ struct ContentView: View {
|
||||
})
|
||||
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
|
||||
Button("content.alert.bluetooth_required.settings") {
|
||||
// Powered-off needs the radio controls, not the privacy pane.
|
||||
(appChromeModel.bluetoothState == .poweredOff
|
||||
? SystemSettings.bluetoothPower
|
||||
: SystemSettings.bluetooth).open()
|
||||
SystemSettings.bluetooth.open()
|
||||
}
|
||||
Button("common.ok", role: .cancel) {}
|
||||
} message: {
|
||||
@ -492,47 +489,14 @@ struct ContentView: View {
|
||||
}
|
||||
|
||||
private var headerView: some View {
|
||||
VStack(spacing: 0) {
|
||||
ContentHeaderView(
|
||||
showSidebar: $showSidebar,
|
||||
showVerifySheet: $showVerifySheet,
|
||||
isNicknameFieldFocused: $isNicknameFieldFocused,
|
||||
headerHeight: headerHeight,
|
||||
headerPeerIconSize: headerPeerIconSize,
|
||||
headerPeerCountFontSize: headerPeerCountFontSize
|
||||
)
|
||||
|
||||
// Failed-wipe outranks connectivity: "your data may still be
|
||||
// here" matters more than "the radio is off".
|
||||
if appChromeModel.panicWipeBlocked {
|
||||
PanicWipeBlockedBanner()
|
||||
}
|
||||
|
||||
if let issue = ConnectivityIssue.resolve(
|
||||
bluetoothState: appChromeModel.bluetoothState,
|
||||
torBlocked: appChromeModel.torBlocked
|
||||
) {
|
||||
ConnectivityStatusBanner(issue: issue)
|
||||
}
|
||||
}
|
||||
// Hosted here rather than on the logo Text so the pending dialog
|
||||
// survives whatever happens to the header chrome.
|
||||
.confirmationDialog(
|
||||
Text(
|
||||
String(localized: "app_info.settings.danger.panic_confirm_title", defaultValue: "wipe all data?", comment: "Title of the confirmation dialog before a panic wipe")
|
||||
),
|
||||
isPresented: $appChromeModel.showPanicConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(role: .destructive) {
|
||||
appChromeModel.panicClearAllData()
|
||||
} label: {
|
||||
Text(
|
||||
String(localized: "app_info.settings.danger.panic_confirm_action", defaultValue: "wipe everything", comment: "Destructive confirmation button that performs the panic wipe")
|
||||
)
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
ContentHeaderView(
|
||||
showSidebar: $showSidebar,
|
||||
showVerifySheet: $showVerifySheet,
|
||||
isNicknameFieldFocused: $isNicknameFieldFocused,
|
||||
headerHeight: headerHeight,
|
||||
headerPeerIconSize: headerPeerIconSize,
|
||||
headerPeerCountFontSize: headerPeerCountFontSize
|
||||
)
|
||||
}
|
||||
|
||||
private var publicMessageList: some View {
|
||||
|
||||
@ -14,8 +14,6 @@ struct FingerprintView: View {
|
||||
let peerID: PeerID
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@ThemedPalette private var palette
|
||||
@State private var aliasDraft: String = ""
|
||||
@State private var didLoadAlias = false
|
||||
|
||||
private var textColor: Color { palette.primary }
|
||||
|
||||
@ -28,21 +26,6 @@ struct FingerprintView: View {
|
||||
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||
static let localAlias = String(
|
||||
localized: "fingerprint.local_alias.label",
|
||||
defaultValue: "local alias",
|
||||
comment: "Label for the local-only alias field on the fingerprint sheet"
|
||||
)
|
||||
static let localAliasPlaceholder = String(
|
||||
localized: "fingerprint.local_alias.placeholder",
|
||||
defaultValue: "name for this person",
|
||||
comment: "Placeholder for the local alias field on the fingerprint sheet"
|
||||
)
|
||||
static let localAliasHint = String(
|
||||
localized: "fingerprint.local_alias.hint",
|
||||
defaultValue: "only on this device. leave blank to use their claimed nickname.",
|
||||
comment: "Explanation under the local alias field"
|
||||
)
|
||||
static func verifyHint(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||
@ -102,26 +85,6 @@ struct FingerprintView: View {
|
||||
.padding()
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
if fingerprintState.canEditLocalAlias {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(verbatim: Strings.localAlias)
|
||||
.bitchatFont(size: 12, weight: .bold)
|
||||
.foregroundColor(textColor.opacity(0.7))
|
||||
|
||||
TextField(Strings.localAliasPlaceholder, text: $aliasDraft)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(textColor)
|
||||
.padding(10)
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.onSubmit { commitAlias() }
|
||||
|
||||
Text(verbatim: Strings.localAliasHint)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(textColor.opacity(0.6))
|
||||
}
|
||||
}
|
||||
|
||||
// Their fingerprint
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
@ -285,37 +248,6 @@ struct FingerprintView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.themedSheetBackground()
|
||||
.onAppear {
|
||||
syncAliasDraft(from: fingerprintState, force: true)
|
||||
}
|
||||
.onChange(of: fingerprintState.theirFingerprint) { _ in
|
||||
// Fingerprint can arrive after the sheet opens; load (or reload)
|
||||
// the saved alias then, otherwise an empty draft looks like a clear.
|
||||
syncAliasDraft(from: fingerprintState, force: false)
|
||||
}
|
||||
.onDisappear {
|
||||
commitAlias()
|
||||
}
|
||||
}
|
||||
|
||||
/// Populate `aliasDraft` from the persisted petname once we know the
|
||||
/// fingerprint. `force` reloads even if we already loaded (onAppear).
|
||||
private func syncAliasDraft(from state: FingerprintPresentationState, force: Bool) {
|
||||
guard state.canEditLocalAlias else { return }
|
||||
if didLoadAlias && !force { return }
|
||||
aliasDraft = state.localPetname ?? ""
|
||||
didLoadAlias = true
|
||||
}
|
||||
|
||||
private func commitAlias() {
|
||||
let fingerprintState = verificationModel.fingerprintPresentation(for: peerID)
|
||||
guard fingerprintState.canEditLocalAlias else { return }
|
||||
// Don't treat "never loaded a draft" as an intentional clear.
|
||||
guard didLoadAlias else { return }
|
||||
let current = fingerprintState.localPetname ?? ""
|
||||
let draft = aliasDraft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard draft != current else { return }
|
||||
verificationModel.setLocalPetname(draft.isEmpty ? nil : draft, for: peerID)
|
||||
}
|
||||
|
||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||
|
||||
@ -64,7 +64,7 @@ struct ImagePickerView: UIViewControllerRepresentable {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
|
||||
@ -14,25 +14,18 @@ struct MacImagePickerView: View {
|
||||
let completion: (URL?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "mac.image_picker.title"
|
||||
static let select = String(localized: "mac.image_picker.select", comment: "Button that opens the macOS open-panel to pick an image")
|
||||
static let panelMessage = String(localized: "mac.image_picker.panel_message", comment: "Message shown in the macOS NSOpenPanel when picking an image")
|
||||
static let cancel = String(localized: "mac.image_picker.cancel", comment: "Cancel button for the macOS image picker sheet")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(Strings.title)
|
||||
Text("Choose an image")
|
||||
.font(.headline)
|
||||
|
||||
Button(Strings.select) {
|
||||
Button("Select Image") {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
|
||||
panel.message = Strings.panelMessage
|
||||
panel.message = "Choose an image to send"
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
completion(panel.url)
|
||||
@ -42,7 +35,7 @@ struct MacImagePickerView: View {
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(Strings.cancel) {
|
||||
Button("Cancel") {
|
||||
completion(nil)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
@ -63,7 +56,7 @@ struct MacImagePickerView: View {
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
|
||||
Text("No image selected")
|
||||
}
|
||||
Button("Show") { isPresented = true }
|
||||
}
|
||||
|
||||
@ -12,10 +12,6 @@ struct LocationChannelsSheet: View {
|
||||
@ThemedPalette private var palette
|
||||
@State private var customGeohash: String = ""
|
||||
@State private var customError: String? = nil
|
||||
/// Geohash waiting on the fine-precision OpSec confirmation before share.
|
||||
@State private var pendingShareGeohash: String?
|
||||
@State private var showSharePrecisionWarning = false
|
||||
@State private var activeSharePayload: ChannelSharePayload?
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "location_channels.title"
|
||||
@ -27,34 +23,11 @@ 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 {
|
||||
String(
|
||||
format: String(localized: "location_channels.quick_join.description", defaultValue: "the region channel where people from %@ tend to gather — the wide cell around the main population center, not your location. it's public and well-known, so assume it's watched: quick join saves typing a geohash; it doesn't hide you or bypass blocks.", comment: "Caption under the quick join row; %@ is the localized country/region name. States plainly that the cell is the main population center's (not the person's location), that the channel must be assumed watched, and that quick join is discovery, not circumvention"),
|
||||
locale: .current,
|
||||
regionName
|
||||
)
|
||||
}
|
||||
static func quickJoinLabel(_ regionName: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_channels.quick_join.join_label", defaultValue: "join the %@ region channel", comment: "Accessibility label for the quick join row; %@ is the localized country/region name"),
|
||||
locale: .current,
|
||||
regionName
|
||||
)
|
||||
}
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
static let shareChannel = String(localized: "channel.share.action", defaultValue: "share channel", comment: "Context-menu / accessibility action that shares a location-channel invite")
|
||||
static let sharePrecisionTitle = String(localized: "channel.share.precision_warning.title", defaultValue: "share a precise location channel?", comment: "Title of the confirmation before sharing a neighborhood-or-finer geohash invite")
|
||||
static let sharePrecisionMessage = String(localized: "channel.share.precision_warning.message", defaultValue: "this channel covers a small area. an invite sent over sms or imessage is visible to the carrier and both handsets — it discloses interest in that place, not only that someone uses bitchat.", comment: "Body of the confirmation before sharing a fine-precision geohash invite")
|
||||
static let shareAnyway = String(localized: "channel.share.precision_warning.confirm", defaultValue: "share anyway", comment: "Confirms sharing a fine-precision location channel after the OpSec warning")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
@ -129,16 +102,6 @@ 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:
|
||||
@ -200,39 +163,6 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
.onChange(of: locationChannelsModel.availableChannels) { _ in }
|
||||
.confirmationDialog(
|
||||
Strings.sharePrecisionTitle,
|
||||
isPresented: $showSharePrecisionWarning,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(Strings.shareAnyway) {
|
||||
if let gh = pendingShareGeohash {
|
||||
presentShare(forGeohash: gh)
|
||||
}
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {
|
||||
pendingShareGeohash = nil
|
||||
}
|
||||
} message: {
|
||||
Text(Strings.sharePrecisionMessage)
|
||||
}
|
||||
.sheet(item: $activeSharePayload) { payload in
|
||||
ShareActivityView(text: payload.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestShare(forGeohash geohash: String) {
|
||||
if ChannelShare.shouldWarn(forGeohash: geohash) {
|
||||
pendingShareGeohash = geohash
|
||||
showSharePrecisionWarning = true
|
||||
} else {
|
||||
presentShare(forGeohash: geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentShare(forGeohash geohash: String) {
|
||||
activeSharePayload = ChannelSharePayload(text: ChannelShare.payload(forGeohash: geohash))
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
@ -274,21 +204,12 @@ struct LocationChannelsSheet: View {
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) },
|
||||
shareGeohash: channel.geohash,
|
||||
onShare: { requestShare(forGeohash: channel.geohash) }
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
|
||||
) {
|
||||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
requestShare(forGeohash: channel.geohash)
|
||||
} label: {
|
||||
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
} else if locationChannelsModel.permissionState == .authorized {
|
||||
@ -315,12 +236,6 @@ struct LocationChannelsSheet: View {
|
||||
customTeleportSection
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if QuickJoinSuggestion.current() != nil {
|
||||
sectionDivider
|
||||
quickJoinSection
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
let bookmarkedList = locationChannelsModel.bookmarks
|
||||
if !bookmarkedList.isEmpty {
|
||||
sectionDivider
|
||||
@ -404,46 +319,6 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// One tap into the region channel around the device region's main
|
||||
/// population center — derived from the locale, no location access, no
|
||||
/// roster (see QuickJoinSuggestion). The caption is deliberately blunt
|
||||
/// that the cell is public and watched: discovery, not circumvention.
|
||||
@ViewBuilder
|
||||
private var quickJoinSection: some View {
|
||||
if let suggestion = QuickJoinSuggestion.current() {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.quickJoinTitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
Button(action: {
|
||||
locationChannelsModel.teleport(to: suggestion.geohash)
|
||||
isPresented = false
|
||||
}) {
|
||||
HStack {
|
||||
Text(verbatim: "\(suggestion.flag) \(suggestion.localizedName)")
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.primary)
|
||||
Spacer()
|
||||
Text(verbatim: "#\(suggestion.geohash)")
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.quickJoinLabel(suggestion.localizedName))
|
||||
.accessibilityHint(Strings.switchChannelHint)
|
||||
|
||||
Text(Strings.quickJoinDescription(suggestion.localizedName))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
@ -472,9 +347,7 @@ struct LocationChannelsSheet: View {
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) },
|
||||
shareGeohash: gh,
|
||||
onShare: { requestShare(forGeohash: gh) }
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
|
||||
) {
|
||||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||
@ -485,13 +358,6 @@ struct LocationChannelsSheet: View {
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
requestShare(forGeohash: gh)
|
||||
} label: {
|
||||
Label(Strings.shareChannel, systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.onAppear { locationChannelsModel.resolveBookmarkNameIfNeeded(for: gh) }
|
||||
|
||||
@ -525,8 +391,6 @@ struct LocationChannelsSheet: View {
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
accessoryActionTitle: String? = nil,
|
||||
accessoryAction: (() -> Void)? = nil,
|
||||
shareGeohash: String? = nil,
|
||||
onShare: (() -> Void)? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
@ -574,9 +438,6 @@ struct LocationChannelsSheet: View {
|
||||
if let accessoryActionTitle, let accessoryAction {
|
||||
Button(accessoryActionTitle, action: accessoryAction)
|
||||
}
|
||||
if shareGeohash != nil, let onShare {
|
||||
Button(Strings.shareChannel, action: onShare)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -48,15 +48,6 @@ 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))
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CoreBluetooth
|
||||
import SwiftUI
|
||||
|
||||
private struct MessageDisplayItem: Identifiable {
|
||||
@ -177,7 +176,7 @@ struct MessageListView: View {
|
||||
// sightings, live hints) stays visible below it instead of
|
||||
// vanishing the moment echoes exist.
|
||||
if privatePeer == nil, showsAmbientFooter(messageItems: messageItems) {
|
||||
MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan)
|
||||
MeshEmptyStateView(compact: true)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 8)
|
||||
@ -297,15 +296,6 @@ struct MessageListView: View {
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
/// Whether the radio could be scanning at all — the empty-state radar
|
||||
/// must not sweep over a switched-off or denied radio.
|
||||
var bluetoothCanScan: Bool {
|
||||
switch appChromeModel.bluetoothState {
|
||||
case .poweredOff, .unauthorized, .unsupported: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
|
||||
var currentContextKey: String {
|
||||
if let peer = privatePeer {
|
||||
return "dm:\(peer)"
|
||||
@ -383,10 +373,7 @@ private extension MessageListView {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
MeshEmptyStateView(
|
||||
fillHeight: max(0, fillHeight - 24),
|
||||
bluetoothAvailable: bluetoothCanScan
|
||||
)
|
||||
MeshEmptyStateView(fillHeight: max(0, fillHeight - 24))
|
||||
case .location(let channel):
|
||||
emptyStateLine(
|
||||
String(
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
//
|
||||
// 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: ", ")
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
//
|
||||
// ShareActivityView.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Hosts the system share UI after an optional OpSec confirmation (#1497).
|
||||
struct ShareActivityView: View {
|
||||
let text: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
#if os(iOS)
|
||||
ShareActivityController(items: [text])
|
||||
.ignoresSafeArea()
|
||||
#elseif os(macOS)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(text)
|
||||
.font(.body)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
HStack {
|
||||
Spacer()
|
||||
ShareLink(item: text) {
|
||||
Label(
|
||||
String(localized: "channel.share.action", defaultValue: "share channel", comment: "Button that opens the system share sheet for a location channel invite"),
|
||||
systemImage: "square.and.arrow.up"
|
||||
)
|
||||
}
|
||||
Button(String(localized: "common.done", defaultValue: "done", comment: "Dismisses a sheet")) {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 360)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
private struct ShareActivityController: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
#endif
|
||||
@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import CoreImage
|
||||
import CoreImage.CIFilterBuiltins
|
||||
import AVFoundation
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
@ -110,27 +109,19 @@ struct ImageWrapper: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Peer verification QR scanner. Uses the camera on iOS and macOS; macOS also
|
||||
/// keeps a paste/validate fallback for machines without a usable camera.
|
||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@ThemedPalette private var palette
|
||||
var isActive: Bool = true
|
||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||
@State private var input = ""
|
||||
@State private var result: String = ""
|
||||
@State private var result: String = "" // not shown for iOS scanner
|
||||
@State private var lastValid: String = ""
|
||||
|
||||
@State private var cameraUnavailable = false
|
||||
|
||||
private enum Strings {
|
||||
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
||||
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||
static let cameraUnavailable = String(
|
||||
localized: "verification.scan.camera_unavailable",
|
||||
defaultValue: "Camera unavailable — paste a QR below.",
|
||||
comment: "Shown over the scanner preview when no camera is available or permission was denied"
|
||||
)
|
||||
static func requested(_ nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||
@ -144,83 +135,69 @@ struct QRScanView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ZStack {
|
||||
CameraScannerView(isActive: isActive, onUnavailable: { cameraUnavailable = true }) { code in
|
||||
handleScannedCode(code, announceResult: false)
|
||||
}
|
||||
if cameraUnavailable {
|
||||
Text(Strings.cameraUnavailable)
|
||||
.bitchatFont(size: 13, weight: .medium)
|
||||
.foregroundColor(palette.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(16)
|
||||
#if os(iOS)
|
||||
CameraScannerView(isActive: isActive) { code in
|
||||
// Deduplicate: ignore if we just processed this exact QR code
|
||||
guard code != lastValid else { return }
|
||||
|
||||
switch verificationModel.verifyScannedPayload(code) {
|
||||
case .requested:
|
||||
// Successfully initiated verification; remember this QR to prevent re-scanning
|
||||
lastValid = code
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
case .notFound, .invalid:
|
||||
// Ignore invalid/no-match reads and keep scanning
|
||||
break
|
||||
}
|
||||
}
|
||||
.frame(height: 260)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
#if os(macOS)
|
||||
#else
|
||||
Text(Strings.pastePrompt)
|
||||
.bitchatFont(size: 14, weight: .medium)
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(palette.secondary.opacity(0.4))
|
||||
Button(Strings.validate) {
|
||||
handleScannedCode(input, announceResult: true)
|
||||
// Deduplicate: ignore if we just processed this exact QR
|
||||
guard input != lastValid else {
|
||||
result = Strings.requested("") // Already processed
|
||||
return
|
||||
}
|
||||
|
||||
switch verificationModel.verifyScannedPayload(input) {
|
||||
case .requested(let nickname):
|
||||
result = Strings.requested(nickname)
|
||||
lastValid = input
|
||||
// Close scanner and return to "My QR" view
|
||||
onSuccess?()
|
||||
case .notFound:
|
||||
result = Strings.notFound
|
||||
case .invalid:
|
||||
result = Strings.invalid
|
||||
}
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
if !result.isEmpty {
|
||||
Text(result)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
#endif
|
||||
// No status text under camera per design
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func handleScannedCode(_ code: String, announceResult: Bool) {
|
||||
guard code != lastValid else {
|
||||
if announceResult {
|
||||
result = Strings.requested("")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch verificationModel.verifyScannedPayload(code) {
|
||||
case .requested(let nickname):
|
||||
lastValid = code
|
||||
if announceResult {
|
||||
result = Strings.requested(nickname)
|
||||
}
|
||||
onSuccess?()
|
||||
case .notFound:
|
||||
if announceResult {
|
||||
result = Strings.notFound
|
||||
}
|
||||
case .invalid:
|
||||
if announceResult {
|
||||
result = Strings.invalid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
import AVFoundation
|
||||
|
||||
struct CameraScannerView: UIViewRepresentable {
|
||||
typealias UIViewType = PreviewView
|
||||
var isActive: Bool
|
||||
var onUnavailable: (() -> Void)? = nil
|
||||
var onCode: (String) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
context.coordinator.setup(
|
||||
previewLayer: view.videoPreviewLayer,
|
||||
onCode: onCode,
|
||||
onUnavailable: onUnavailable
|
||||
)
|
||||
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
||||
context.coordinator.setActive(isActive)
|
||||
return view
|
||||
}
|
||||
@ -229,7 +206,68 @@ struct CameraScannerView: UIViewRepresentable {
|
||||
context.coordinator.setActive(isActive)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
|
||||
func makeCoordinator() -> Coordinator { Coordinator() }
|
||||
|
||||
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private var onCode: ((String) -> Void)?
|
||||
private weak var owner: PreviewView?
|
||||
private let session = AVCaptureSession()
|
||||
private var isRunning = false
|
||||
private var permissionGranted = false
|
||||
private var desiredActive = false
|
||||
|
||||
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
||||
self.owner = sessionOwner
|
||||
self.onCode = onCode
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else { return }
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else { return }
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||
output.metadataObjectTypes = [.qr]
|
||||
}
|
||||
session.commitConfiguration()
|
||||
sessionOwner.videoPreviewLayer.session = session
|
||||
// Request permission and start
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
self.permissionGranted = granted
|
||||
if granted && self.desiredActive && !self.isRunning {
|
||||
self.setActive(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
desiredActive = active
|
||||
guard permissionGranted else { return }
|
||||
if active && !isRunning {
|
||||
isRunning = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !self.session.isRunning { self.session.startRunning() }
|
||||
}
|
||||
} else if !active && isRunning {
|
||||
isRunning = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
||||
for obj in metadataObjects {
|
||||
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||
m.type == .qr,
|
||||
let str = m.stringValue else { continue }
|
||||
onCode?(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
@ -241,166 +279,8 @@ struct CameraScannerView: UIViewRepresentable {
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
struct CameraScannerView: NSViewRepresentable {
|
||||
typealias NSViewType = PreviewView
|
||||
var isActive: Bool
|
||||
var onUnavailable: (() -> Void)? = nil
|
||||
var onCode: (String) -> Void
|
||||
|
||||
func makeNSView(context: Context) -> PreviewView {
|
||||
let view = PreviewView()
|
||||
context.coordinator.setup(
|
||||
previewLayer: view.videoPreviewLayer,
|
||||
onCode: onCode,
|
||||
onUnavailable: onUnavailable
|
||||
)
|
||||
context.coordinator.setActive(isActive)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PreviewView, context: Context) {
|
||||
context.coordinator.setActive(isActive)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> CameraScannerCoordinator { CameraScannerCoordinator() }
|
||||
|
||||
final class PreviewView: NSView {
|
||||
let videoPreviewLayer = AVCaptureVideoPreviewLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
videoPreviewLayer.videoGravity = .resizeAspectFill
|
||||
layer = CALayer()
|
||||
layer?.addSublayer(videoPreviewLayer)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
videoPreviewLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class CameraScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
||||
private var onCode: ((String) -> Void)?
|
||||
private var onUnavailable: (() -> Void)?
|
||||
private let session = AVCaptureSession()
|
||||
private var isRunning = false
|
||||
private var permissionGranted = false
|
||||
private var desiredActive = false
|
||||
private var didConfigureSession = false
|
||||
private weak var previewLayer: AVCaptureVideoPreviewLayer?
|
||||
|
||||
func setup(
|
||||
previewLayer: AVCaptureVideoPreviewLayer,
|
||||
onCode: @escaping (String) -> Void,
|
||||
onUnavailable: (() -> Void)? = nil
|
||||
) {
|
||||
self.onCode = onCode
|
||||
self.onUnavailable = onUnavailable
|
||||
self.previewLayer = previewLayer
|
||||
previewLayer.session = session
|
||||
|
||||
// Check authorization before creating AVCaptureDeviceInput so tests and
|
||||
// cold launches do not trigger a TCC prompt just by constructing input.
|
||||
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
||||
case .authorized:
|
||||
permissionGranted = true
|
||||
if !configureSessionIfNeeded() {
|
||||
reportUnavailable()
|
||||
}
|
||||
case .notDetermined:
|
||||
AVCaptureDevice.requestAccess(for: .video) { granted in
|
||||
DispatchQueue.main.async {
|
||||
self.permissionGranted = granted
|
||||
if granted {
|
||||
if !self.configureSessionIfNeeded() {
|
||||
self.reportUnavailable()
|
||||
return
|
||||
}
|
||||
if self.desiredActive && !self.isRunning {
|
||||
self.setActive(true)
|
||||
}
|
||||
} else {
|
||||
self.reportUnavailable()
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
permissionGranted = false
|
||||
reportUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func configureSessionIfNeeded() -> Bool {
|
||||
guard !didConfigureSession else { return true }
|
||||
session.beginConfiguration()
|
||||
session.sessionPreset = .high
|
||||
guard let device = AVCaptureDevice.default(for: .video),
|
||||
let input = try? AVCaptureDeviceInput(device: device),
|
||||
session.canAddInput(input) else {
|
||||
session.commitConfiguration()
|
||||
return false
|
||||
}
|
||||
session.addInput(input)
|
||||
let output = AVCaptureMetadataOutput()
|
||||
guard session.canAddOutput(output) else {
|
||||
session.commitConfiguration()
|
||||
return false
|
||||
}
|
||||
session.addOutput(output)
|
||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
||||
if output.availableMetadataObjectTypes.contains(.qr) {
|
||||
output.metadataObjectTypes = [.qr]
|
||||
}
|
||||
session.commitConfiguration()
|
||||
previewLayer?.session = session
|
||||
didConfigureSession = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func reportUnavailable() {
|
||||
DispatchQueue.main.async {
|
||||
self.onUnavailable?()
|
||||
}
|
||||
}
|
||||
|
||||
func setActive(_ active: Bool) {
|
||||
desiredActive = active
|
||||
guard permissionGranted, didConfigureSession else { return }
|
||||
if active && !isRunning {
|
||||
isRunning = true
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if !self.session.isRunning { self.session.startRunning() }
|
||||
}
|
||||
} else if !active && isRunning {
|
||||
isRunning = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func metadataOutput(
|
||||
_ output: AVCaptureMetadataOutput,
|
||||
didOutput metadataObjects: [AVMetadataObject],
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
for obj in metadataObjects {
|
||||
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
||||
m.type == .qr,
|
||||
let str = m.stringValue else { continue }
|
||||
onCode?(str)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combined sheet: shows my QR by default with a button to scan instead
|
||||
struct VerificationSheetView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@ -440,12 +320,19 @@ struct VerificationSheetView: View {
|
||||
.frame(maxWidth: .infinity)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(accentColor)
|
||||
#if os(iOS)
|
||||
QRScanView(isActive: showingScanner, onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(verificationModel)
|
||||
.frame(minHeight: 280)
|
||||
.frame(height: 280)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
#else
|
||||
QRScanView(onSuccess: {
|
||||
showingScanner = false
|
||||
})
|
||||
.environmentObject(verificationModel)
|
||||
#endif
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@ -10,8 +10,6 @@
|
||||
</array>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.personal-information.location</key>
|
||||
|
||||
@ -69,11 +69,7 @@ private func makeArchitectureMessage(
|
||||
|
||||
@MainActor
|
||||
private func waitUntil(
|
||||
// Settle deadline, not a latency budget (see TestConstants.settleTimeout):
|
||||
// the old 3s default flaked on CI the moment a Combine hop through
|
||||
// receive(on: .main) was starved. Every caller waits for a condition to
|
||||
// become true, so passing runs return immediately.
|
||||
timeoutNanoseconds: UInt64 = UInt64(TestConstants.settleTimeout * 1_000_000_000),
|
||||
timeoutNanoseconds: UInt64 = 3_000_000_000,
|
||||
pollNanoseconds: UInt64 = 20_000_000,
|
||||
_ condition: @escaping @MainActor () -> Bool
|
||||
) async {
|
||||
@ -462,146 +458,6 @@ struct AppArchitectureTests {
|
||||
#expect(chromeModel.showingFingerprintFor == nil)
|
||||
}
|
||||
|
||||
@Test("Triple-tap panic entry point only raises the confirmation dialog")
|
||||
@MainActor
|
||||
func requestPanicWipeAsksBeforeDestroying() {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
viewModel.seedPublicMessages([
|
||||
BitchatMessage(
|
||||
id: "keep-1",
|
||||
sender: "Tester",
|
||||
content: "still here",
|
||||
timestamp: Date(),
|
||||
isRelay: false
|
||||
)
|
||||
])
|
||||
|
||||
chromeModel.requestPanicWipe()
|
||||
|
||||
// The gesture must never wipe directly — a fumbled tap on the logo
|
||||
// (single-tap opens App Info) cannot be allowed to destroy identity.
|
||||
#expect(chromeModel.showPanicConfirmation)
|
||||
#expect(viewModel.messages.map(\.id) == ["keep-1"])
|
||||
}
|
||||
|
||||
@Test("Panic wipe dismisses chrome sheets so its outcome is visible")
|
||||
@MainActor
|
||||
func panicWipeDismissesChromePresentation() {
|
||||
let viewModel = makeArchitectureViewModel()
|
||||
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
|
||||
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
|
||||
|
||||
// The App Info danger-zone path wipes while its own sheet is up; the
|
||||
// success message and the failed-wipe banner both render on the root
|
||||
// timeline, so any sheet left presented would hide the one signal
|
||||
// that says whether the wipe worked.
|
||||
chromeModel.presentAppInfo()
|
||||
chromeModel.isLocationChannelsSheetPresented = true
|
||||
chromeModel.presentNotices()
|
||||
chromeModel.showFingerprint(for: PeerID(str: "peer-3"))
|
||||
|
||||
chromeModel.panicClearAllData()
|
||||
|
||||
#expect(!chromeModel.isAppInfoPresented)
|
||||
#expect(!chromeModel.isLocationChannelsSheetPresented)
|
||||
#expect(!chromeModel.isNoticesSheetPresented)
|
||||
#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.
|
||||
//
|
||||
// Re-assert the tracker state on every poll: the view model's own
|
||||
// channel binding delivers its initial .mesh selection asynchronously
|
||||
// and resets the active participant geohash when it lands
|
||||
// (GeohashSubscriptionManager.setActiveParticipantGeohash(nil)) — on
|
||||
// a loaded parallel runner that reset arrives AFTER this setup and
|
||||
// the dedup can never happen. Both calls are idempotent, so the
|
||||
// interference heals on the next poll while a genuine dedup failure
|
||||
// still times out.
|
||||
await waitUntil {
|
||||
viewModel.participantTracker.setActiveGeohash("u4pruy")
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: "u4pruy")
|
||||
return !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,30 +36,6 @@ 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")
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
//
|
||||
// ChannelShareTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct ChannelShareTests {
|
||||
@Test func payloadIncludesGeohashDeepLinkAndStoreURL() {
|
||||
let text = ChannelShare.payload(forGeohash: "u4pru")
|
||||
#expect(text.contains("#u4pru"))
|
||||
#expect(text.contains("bitchat://geohash/u4pru"))
|
||||
#expect(text.contains(ChannelShare.appStoreURL))
|
||||
#expect(!text.lowercased().contains("i'm in"))
|
||||
}
|
||||
|
||||
@Test func precisionWarningStartsAtNeighborhood() {
|
||||
#expect(!ChannelShare.shouldWarn(forGeohash: "u4pru")) // city = 5
|
||||
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruy")) // neighborhood = 6
|
||||
#expect(ChannelShare.shouldWarn(forGeohash: "u4pruyzd"))
|
||||
}
|
||||
}
|
||||
@ -7,10 +7,12 @@
|
||||
// `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@ -40,6 +42,7 @@ 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
|
||||
@ -76,6 +79,8 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
work()
|
||||
}
|
||||
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
|
||||
// Peers & sessions
|
||||
var nicknamesByPeerID: [PeerID: String] = [:]
|
||||
var peersByID: [PeerID: BitchatPeer] = [:]
|
||||
@ -94,6 +99,7 @@ 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) {
|
||||
@ -106,12 +112,20 @@ 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] = [:]
|
||||
@ -250,39 +264,40 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async {
|
||||
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
|
||||
let context = MockChatLifecycleContext()
|
||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.nicknamesByPeerID[peerID] = "alice"
|
||||
|
||||
// No established session: nothing goes out, so nothing is echoed —
|
||||
// a local "you took a screenshot" would imply the peer was told.
|
||||
// No established session: local notice only, no network send.
|
||||
coordinator.handleScreenshotCaptured()
|
||||
#expect(context.routedPrivateMessages.isEmpty)
|
||||
#expect(context.privateChats.isEmpty)
|
||||
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
|
||||
// Established session: the peer is notified and the echo appears.
|
||||
// Established session: the peer is notified too.
|
||||
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]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
#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)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_publicChannel_staysSilent() async {
|
||||
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() 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.routedPrivateMessages.isEmpty)
|
||||
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
|
||||
#expect(context.systemMessages == ["you took a screenshot"])
|
||||
#expect(context.privateChats.isEmpty)
|
||||
}
|
||||
|
||||
@ -350,59 +365,3 @@ 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,7 +653,8 @@ 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 }, timeout: TestConstants.settleTimeout)
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
#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 }, timeout: TestConstants.settleTimeout)
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
#expect(delivered)
|
||||
}
|
||||
|
||||
|
||||
@ -360,37 +360,6 @@ 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()
|
||||
@ -414,10 +383,6 @@ struct ChatPeerIdentityCoordinatorContextTests {
|
||||
)
|
||||
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
|
||||
|
||||
// Local alias outranks a live mesh announce for the same peer.
|
||||
context.nicknamesByPeerID[identityPeer] = "bob"
|
||||
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
|
||||
|
||||
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
|
||||
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
|
||||
}
|
||||
|
||||
@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
viewModel.handleNostrEvent(signed)
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
timeout: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#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: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#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: TestConstants.settleTimeout
|
||||
timeout: 5.0
|
||||
)
|
||||
#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 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)
|
||||
// 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)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#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.settleTimeout
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#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.settleTimeout)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#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.settleTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
#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.settleTimeout)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
|
||||
@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async {
|
||||
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
@ -437,10 +437,8 @@ 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]?.contains { $0.content == "you took a screenshot" } != true)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
@ -2263,9 +2261,7 @@ struct ChatViewModelPanicTests {
|
||||
|
||||
// After panic, emergency disconnect should be called
|
||||
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||
// Pre-panic content is gone; the only survivor is the system message
|
||||
// confirming the wipe (in duress, "did it work?" must not be a guess).
|
||||
#expect(viewModel.messages.map(\.sender) == ["system"])
|
||||
#expect(viewModel.messages.isEmpty)
|
||||
#expect(viewModel.privateChats.isEmpty)
|
||||
#expect(viewModel.unreadPrivateMessages.isEmpty)
|
||||
#expect(viewModel.selectedPrivateChatPeer == nil)
|
||||
|
||||
@ -165,33 +165,14 @@ struct ChatViewModelTorTests {
|
||||
viewModel.torStatusAnnounced = true
|
||||
viewModel.torInitialReadyAnnounced = true
|
||||
viewModel.torRestartPending = true
|
||||
viewModel.torBlocked = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert: all flags reset — torBlocked drives the connectivity
|
||||
// banner; toggling tor off must not leave a stale "tor can't
|
||||
// connect" line for a network nobody is waiting on.
|
||||
// Assert: all flags reset
|
||||
#expect(!viewModel.torStatusAnnounced)
|
||||
#expect(!viewModel.torInitialReadyAnnounced)
|
||||
#expect(!viewModel.torRestartPending)
|
||||
#expect(!viewModel.torBlocked)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleTorDidBecomeReady_clearsBlockedBanner() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
// Setup: a stalled bootstrap raised the banner, then tor got through.
|
||||
viewModel.torBlocked = true
|
||||
|
||||
// Action
|
||||
viewModel.handleTorDidBecomeReady()
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
// Assert
|
||||
#expect(!viewModel.torBlocked)
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,9 +255,7 @@ struct CommandProcessorTests {
|
||||
}
|
||||
switch blockResult {
|
||||
case .success(let message):
|
||||
// "no longer see" — blocking filters at display time; packets still
|
||||
// arrive and relay, so "receive" overpromised (UX audit fix).
|
||||
#expect(message == "blocked bob. you will no longer see their messages")
|
||||
#expect(message == "blocked bob. you will no longer receive messages from them")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
//
|
||||
// ConnectivityStatusTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CoreBluetooth
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct ConnectivityIssueTests {
|
||||
|
||||
@Test("Bluetooth problems outrank a tor stall; healthy state shows nothing")
|
||||
func resolvePrioritizesBluetoothOverTor() {
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOff, torBlocked: true) == .bluetoothOff)
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .unauthorized, torBlocked: false) == .bluetoothDenied)
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .unsupported, torBlocked: false) == .bluetoothUnsupported)
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: true) == .torBlocked)
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .poweredOn, torBlocked: false) == nil)
|
||||
}
|
||||
|
||||
@Test("A starting radio must not flash a false 'bluetooth is off' banner")
|
||||
func resolveStaysQuietWhileRadioIsStarting() {
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: false) == nil)
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .resetting, torBlocked: false) == nil)
|
||||
// A tor stall still surfaces once known, even while the radio starts.
|
||||
#expect(ConnectivityIssue.resolve(bluetoothState: .unknown, torBlocked: true) == .torBlocked)
|
||||
}
|
||||
}
|
||||
@ -115,18 +115,13 @@ 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: 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)
|
||||
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)
|
||||
} catch {
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
@ -139,7 +134,7 @@ struct PrivateChatE2ETests {
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID)
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
@ -160,7 +155,7 @@ struct PrivateChatE2ETests {
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID)
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||
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")
|
||||
helper.createNode("Bob")
|
||||
helper.createNode("Charlie")
|
||||
helper.createNode("David")
|
||||
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))
|
||||
}
|
||||
|
||||
// MARK: - Multi-Peer Scenarios
|
||||
@ -266,11 +266,9 @@ struct IntegrationTests {
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
|
||||
// Simulate Bob restart by recreating his Noise manager. A new static
|
||||
// key means a new key-derived wire ID, just like production.
|
||||
// Simulate Bob restart by recreating his Noise manager
|
||||
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 {
|
||||
@ -322,7 +320,7 @@ struct IntegrationTests {
|
||||
@Test func largeScaleNetwork() async throws {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
helper.createNode("Node\(i)")
|
||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(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)
|
||||
helper.createNode(name, peerID: PeerID(str: UUID().uuidString))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,17 +22,15 @@ final class TestNetworkHelper {
|
||||
// MARK: - Node/Manager management
|
||||
|
||||
@discardableResult
|
||||
func createNode(_ name: String) -> MockBLEService {
|
||||
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||
let node = MockBLEService(bus: bus)
|
||||
// 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.myPeerID = peerID
|
||||
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,
|
||||
|
||||
@ -69,45 +69,4 @@ struct LocalizationCoverageTests {
|
||||
let missing = main.allLocales.subtracting(shareExt.allLocales).sorted()
|
||||
#expect(missing.isEmpty, "share extension is missing locales: \(missing.joined(separator: ", "))")
|
||||
}
|
||||
|
||||
/// The catalog tests above validate the CATALOG; this one validates the
|
||||
/// CODE. A `String(localized:)` whose key is absent from every catalog
|
||||
/// compiles and runs fine — it just silently ships its English
|
||||
/// `defaultValue` to all 29 non-source locales. That blind spot let the
|
||||
/// entire notices/board composer (10 keys), two delivery states, and two
|
||||
/// media-failure reasons go untranslated while the coverage tests stayed
|
||||
/// green. Interpolated keys can't be checked statically and are skipped;
|
||||
/// every literal key must resolve.
|
||||
@Test func everyCodeReferencedKeyExistsInACatalog() throws {
|
||||
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
|
||||
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||
let knownKeys = Set(main.coverage.keys).union(shareExt.coverage.keys)
|
||||
|
||||
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
|
||||
var missing: [String] = []
|
||||
|
||||
for sourceRoot in ["bitchat", "bitchatShareExtension"] {
|
||||
let rootURL = Self.repoRoot.appendingPathComponent(sourceRoot)
|
||||
let enumerator = try #require(FileManager.default.enumerator(
|
||||
at: rootURL,
|
||||
includingPropertiesForKeys: nil
|
||||
))
|
||||
for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" {
|
||||
let source = try String(contentsOf: fileURL, encoding: .utf8)
|
||||
let range = NSRange(source.startIndex..., in: source)
|
||||
pattern.enumerateMatches(in: source, range: range) { match, _, _ in
|
||||
guard let match, let keyRange = Range(match.range(at: 1), in: source) else { return }
|
||||
let key = String(source[keyRange])
|
||||
if !knownKeys.contains(key) {
|
||||
missing.append("\(key) (\(fileURL.lastPathComponent))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#expect(
|
||||
missing.isEmpty,
|
||||
"keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -578,26 +578,6 @@ 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,29 +68,22 @@ struct NoiseProtocolTests {
|
||||
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
// 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 alicePeerID = PeerID(str: UUID().uuidString)
|
||||
private let bobPeerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
private let aliceSession: NoiseSession
|
||||
private let bobSession: NoiseSession
|
||||
|
||||
|
||||
init() {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation),
|
||||
peerID: alicePeerID,
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
|
||||
bobSession = NoiseSession(
|
||||
peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation),
|
||||
peerID: bobPeerID,
|
||||
role: .responder,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: bobKey
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
//
|
||||
// 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,12 +150,14 @@ 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,19 +39,6 @@ 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,21 +56,7 @@ struct SimulatedMeshTests {
|
||||
mesh.connect(1, 2)
|
||||
|
||||
mesh.announceAll()
|
||||
// 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
|
||||
}
|
||||
mesh.advanceTime(by: 2)
|
||||
let baseline = mesh.deliveredFrameCount
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
|
||||
@ -803,29 +803,18 @@ struct ViewSmokeTests {
|
||||
#expect(deliveryStatusSnapshot(of: mediaRow) == read)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
@Test
|
||||
func cameraScannerView_previewAndCoordinatorSmoke() {
|
||||
#if os(iOS) || os(macOS)
|
||||
// Avoid constructing AVCaptureDeviceInput (and the TCC prompt it can
|
||||
// trigger) unless the host process already has camera authorization —
|
||||
// same class of isolation as keeping tests off the login keychain.
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
let preview = CameraScannerView.PreviewView(frame: .zero)
|
||||
let coordinator = CameraScannerCoordinator()
|
||||
let coordinator = CameraScannerView.Coordinator()
|
||||
|
||||
#if os(iOS)
|
||||
_ = CameraScannerView.PreviewView.layerClass
|
||||
#elseif os(macOS)
|
||||
preview.layout()
|
||||
#endif
|
||||
_ = preview.videoPreviewLayer
|
||||
|
||||
if status == .authorized {
|
||||
coordinator.setup(previewLayer: preview.videoPreviewLayer) { _ in }
|
||||
coordinator.setActive(false)
|
||||
}
|
||||
coordinator.setup(sessionOwner: preview) { _ in }
|
||||
coordinator.setActive(false)
|
||||
|
||||
#expect(preview.videoPreviewLayer.videoGravity == .resizeAspectFill)
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1,342 +0,0 @@
|
||||
# Peer ID Rotation Specification
|
||||
|
||||
**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh.
|
||||
**Audience:** bitchat iOS and bitchat Android maintainers.
|
||||
**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone.
|
||||
|
||||
**Where the code is:**
|
||||
|
||||
| Piece | File |
|
||||
|---|---|
|
||||
| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` |
|
||||
| `announceV2 = 0x2C` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` |
|
||||
| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` |
|
||||
| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` |
|
||||
|
||||
The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4).
|
||||
|
||||
Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this:
|
||||
|
||||
- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one.
|
||||
- **Not padded** (`BLEOutboundPacketPolicy`). At ~75 bytes the smallest bucket would triple the airtime of the most frequent packet in the protocol. The format is already near-constant width; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument.
|
||||
- **Parsed but ignored** on receive (`BLEService`), as above.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation:
|
||||
|
||||
1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant.
|
||||
2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city.
|
||||
3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 4–30 seconds and on demand to anything that connects and subscribes.
|
||||
4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration.
|
||||
|
||||
For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range.
|
||||
|
||||
iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it.
|
||||
|
||||
**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all.
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- **G1.** A passive listener cannot link two observations of the same device across rotation periods.
|
||||
- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname.
|
||||
- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress.
|
||||
- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met.
|
||||
- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day.
|
||||
- **G6.** Rotation does not make identity spoofing easier than it is today.
|
||||
|
||||
**Non-goals, explicitly out of scope here**
|
||||
|
||||
- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately.
|
||||
- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation.
|
||||
- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for.
|
||||
|
||||
## 3. What currently binds an identity, and why rotation breaks it
|
||||
|
||||
This is the part most likely to be underestimated, so it is stated precisely.
|
||||
|
||||
`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places:
|
||||
|
||||
**Announce preflight** — `BLEAnnounceHandlingPolicy.swift:32-35`:
|
||||
|
||||
```swift
|
||||
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||
guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) }
|
||||
```
|
||||
|
||||
**Handshake completion** — `NoiseSessionManager.swift:1106-1122`:
|
||||
|
||||
```swift
|
||||
private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID) -> Bool {
|
||||
let rawKey = remoteKey.rawRepresentation
|
||||
if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID }
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Failure throws `NoiseSessionError.peerIdentityMismatch`.
|
||||
|
||||
If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch.
|
||||
|
||||
Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong.
|
||||
|
||||
## 4. Design
|
||||
|
||||
### 4.1 Epochs
|
||||
|
||||
Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation.
|
||||
|
||||
```
|
||||
epoch = floor(unixTimeSeconds / ROTATION_PERIOD)
|
||||
ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1)
|
||||
```
|
||||
|
||||
`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`).
|
||||
|
||||
### 4.2 The rotating peer ID
|
||||
|
||||
```
|
||||
K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey,
|
||||
salt: "",
|
||||
info: "bitchat-peer-rotation-v1",
|
||||
length: 32)
|
||||
|
||||
peerID_e = HMAC-SHA256(key: K_rot,
|
||||
message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8]
|
||||
```
|
||||
|
||||
Properties:
|
||||
|
||||
- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable.
|
||||
- Deterministic, so the device recomputes the same ID after a restart within the same epoch.
|
||||
- Still 8 bytes, so the packet header layout is unchanged.
|
||||
|
||||
**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern.
|
||||
|
||||
### 4.3 Recognising peers you already know
|
||||
|
||||
With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B:
|
||||
|
||||
```
|
||||
S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub)
|
||||
K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32)
|
||||
|
||||
tag_A→B = HMAC-SHA256(key: K_AB,
|
||||
message: uint32be(epoch)
|
||||
|| A_noiseStaticPublic (32)
|
||||
|| B_noiseStaticPublic (32)
|
||||
|| peerID_e (8))[0..8]
|
||||
```
|
||||
|
||||
A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys.
|
||||
|
||||
Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose.
|
||||
|
||||
**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys.
|
||||
|
||||
**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence.
|
||||
|
||||
**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4.
|
||||
|
||||
Rules:
|
||||
|
||||
- Tags are **unordered**. Implementations MUST NOT infer anything from position.
|
||||
- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it.
|
||||
- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.)
|
||||
- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated.
|
||||
|
||||
### 4.4 Strangers
|
||||
|
||||
Nothing identifying is broadcast for strangers. Discovery still works:
|
||||
|
||||
1. A hears an announce from unknown `peerID_e` advertising the rotation capability.
|
||||
2. A initiates Noise **XX** to that ID.
|
||||
3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither.
|
||||
4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted.
|
||||
|
||||
So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite.
|
||||
|
||||
### 4.5 The replacement binding
|
||||
|
||||
Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key:
|
||||
|
||||
```
|
||||
proof = Ed25519-Sign(signingPrivateKey,
|
||||
"bitchat-peerid-binding-v1"
|
||||
|| uint32be(epoch)
|
||||
|| peerID_e (8 bytes)
|
||||
|| noiseStaticPublicKey (32 bytes))
|
||||
```
|
||||
|
||||
Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies:
|
||||
|
||||
- **that the `noiseStaticPublicKey` inside the proof is byte-equal to the remote static key the Noise session actually established** — see below, this one is load-bearing, and
|
||||
- the signature against the signing key in the same packet, **and**
|
||||
- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and
|
||||
- that `peerID_e` equals the ID the session was actually conducted under, and
|
||||
- that `epoch` is within the ±1 window.
|
||||
|
||||
An earlier draft of this list omitted the first check, which left a hole worth spelling out because it is the kind that survives review. The proof is a self-contained signed blob: nothing in the signature ties it to *the session it arrives on*. So a peer M who has observed A's proof — it travels inside a session, but M can be a peer A legitimately talked to — could replay A's proof verbatim inside M's own session with B. Without the static-key check, B verifies A's signature successfully, sees a well-formed binding, and on **first contact** TOFU-pins A's signing key against M's fingerprint. From then on B attributes M's identity to A's key. Comparing the proof's static key against the key the handshake actually produced closes it: M cannot substitute A's key without also being A.
|
||||
|
||||
This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. With the static-key check present it is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message. Without it, it is weaker — a reminder that "signed" and "bound to this conversation" are different properties.
|
||||
|
||||
Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused.
|
||||
|
||||
### 4.6 The announce, before and after
|
||||
|
||||
**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext:
|
||||
|
||||
| T | Field | Width |
|
||||
|---|---|---|
|
||||
| `0x01` | nickname | var |
|
||||
| `0x02` | Noise static public key | 32 |
|
||||
| `0x03` | Ed25519 signing public key | 32 |
|
||||
| `0x04` | direct neighbours | N × 8, max 10 |
|
||||
| `0x05` | capabilities | 1–8 |
|
||||
| `0x06` | bridge geohash | var |
|
||||
|
||||
`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`).
|
||||
|
||||
**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type: **`announceV2 = 0x2C`**.
|
||||
|
||||
An earlier draft proposed `0x05` on the grounds that it is unassigned today and sits next to `announce = 0x01`. That was wrong. `0x05` has already been recycled twice — `announce`, then `bulkTransferResponse`, then `fragmentStart` until #446 — so a sufficiently old peer may still map it to a fragment header and misparse presence as a partial message. Values above `voiceFrame = 0x29` have only ever been allocated forward, which is the safe direction; `0x2A`/`0x2B` are spoken for by the courier spray-ack work, leaving `0x2C`. Verified never used anywhere in this repository's history (see O3).
|
||||
|
||||
TLVs, all cleartext but none identifying:
|
||||
|
||||
| T | Field | Width | Notes |
|
||||
|---|---|---|---|
|
||||
| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing |
|
||||
| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded |
|
||||
| `0x03` | capabilities | 1–8 | same minimal-LE encoding as today |
|
||||
| `0x04` | bridge geohash | ≤12 | unchanged semantics |
|
||||
|
||||
Deliberately absent: nickname, both public keys, neighbour list.
|
||||
|
||||
Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it.
|
||||
|
||||
- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own.
|
||||
- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.)
|
||||
|
||||
**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences:
|
||||
|
||||
- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today.
|
||||
- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys.
|
||||
- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot.
|
||||
|
||||
Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers.
|
||||
|
||||
## 5. Compatibility and rollout
|
||||
|
||||
The repo already has the two mechanisms this needs, both proven in production.
|
||||
|
||||
**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit — **bit 14** at the time of writing: bit 10 is burned and MUST NOT be reused, bit 11 is claimed by the Nostr double-ratchet work (#1107), bit 12 by courier spray receipts (#1438), and bit 13 is reserved for stickers (#1544). Re-check the claim table in `PeerCapabilities.swift` before assigning; whichever platform implements first pins the number in a shared test vector.
|
||||
|
||||
**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here.
|
||||
|
||||
**Phased plan.**
|
||||
|
||||
| Phase | Behaviour |
|
||||
|---|---|
|
||||
| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. |
|
||||
| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. |
|
||||
| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. |
|
||||
|
||||
During phases 2–3 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation.
|
||||
|
||||
## 6. Impact inventory
|
||||
|
||||
This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents.
|
||||
|
||||
### 6.1 Must be fixed or the feature is broken
|
||||
|
||||
| Area | Why | iOS reference |
|
||||
|---|---|---|
|
||||
| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` |
|
||||
| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` |
|
||||
| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` |
|
||||
| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` |
|
||||
| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` |
|
||||
| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` |
|
||||
| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` |
|
||||
|
||||
### 6.2 Degrades gracefully but needs handling
|
||||
|
||||
| Area | Effect | iOS reference |
|
||||
|---|---|---|
|
||||
| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` |
|
||||
| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` |
|
||||
| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` |
|
||||
| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` |
|
||||
| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` |
|
||||
| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` |
|
||||
|
||||
### 6.3 Already safe — no work needed
|
||||
|
||||
Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped.
|
||||
|
||||
## 7. Test vectors
|
||||
|
||||
These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table.
|
||||
|
||||
All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift.
|
||||
|
||||
With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32):
|
||||
|
||||
```
|
||||
rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: <empty>,
|
||||
info: "bitchat-peer-rotation-v1", len: 32)
|
||||
= fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09
|
||||
|
||||
peerID(epoch=100) = HMAC-SHA256(rotationSecret,
|
||||
"bitchat-peer-id-v2" || uint32be(100))[0..8]
|
||||
= f7c08c528506a374
|
||||
```
|
||||
|
||||
With a recognition key derived from a shared secret of 32 × `0x42`, sender key
|
||||
32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`:
|
||||
|
||||
```
|
||||
recognitionKey = HKDF-SHA256(ikm: 42×32, salt: <empty>,
|
||||
info: "bitchat-recognition-v1", len: 32)
|
||||
|
||||
tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey,
|
||||
uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8]
|
||||
= 4568f61d61d6cbfb
|
||||
|
||||
tag_B→A(epoch=100) (same key, keys swapped)
|
||||
= 5313c7731f629959
|
||||
```
|
||||
|
||||
Both directions are given because their *difference* is the security property: if
|
||||
an implementation produces the same value for both, it has reintroduced the
|
||||
symmetric-tag flaw.
|
||||
|
||||
Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position.
|
||||
|
||||
Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code.
|
||||
|
||||
## 8. Open questions for review
|
||||
|
||||
- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin?
|
||||
- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable?
|
||||
- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable?
|
||||
- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes.
|
||||
- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most.
|
||||
- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state?
|
||||
- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (encoded *frames* of 241–256, 497–768 and 1009–1792 bytes ship at exact length today — the arithmetic is over the whole encoded packet that `pad` receives, not the payload alone). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one.
|
||||
|
||||
- **O9 — A seized device recomputes every past peer ID.** `K_rot` is a long-lived secret, so `peerID_e = HMAC(K_rot, epoch)` is computable for *any* epoch by whoever holds it. Someone who seizes a phone, or extracts the Noise static key from a backup, can therefore take historical radio captures and identify which of them were this device — retroactively defeating the unlinkability for every past epoch. Rotation protects against the passive observer, not against later key compromise. A hash ratchet (`K_{e+1} = HKDF(K_e)`, discarding `K_e`) would give forward secrecy for the ID stream, at the cost of state that must survive restarts, tolerate clock jumps, and resynchronise after a gap — none of which is free, and all of which interacts with the ±1 window. Worth deciding deliberately rather than inheriting.
|
||||
|
||||
## 9. Relationship to other work
|
||||
|
||||
Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper:
|
||||
|
||||
- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR.
|
||||
- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7.
|
||||
|
||||
None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable.
|
||||
@ -1,158 +0,0 @@
|
||||
//
|
||||
// AnnounceV2Packet.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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
|
||||
/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing
|
||||
/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus
|
||||
/// the local social graph, free to anyone in radio range. This carries none of
|
||||
/// it — only an epoch, a fixed-size block of pairwise recognition tags, and
|
||||
/// capability bits.
|
||||
///
|
||||
/// Deliberately absent, with reasons:
|
||||
/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX
|
||||
/// handshake, where they are already encrypted on the wire.
|
||||
/// - **Nickname**: a self-chosen, frequently reused human label. It moves into
|
||||
/// the session (`AuthenticatedPeerStatePacket`).
|
||||
/// - **Neighbour list**: it seeds source routing, whose documented fallback is
|
||||
/// flooding. Publishing a crowd's adjacency graph is not a reasonable price
|
||||
/// for routing efficiency.
|
||||
///
|
||||
/// **Unsigned, on purpose and not without cost.** There is no key to verify a
|
||||
/// signature against without disclosing one, so this asserts only "somebody is
|
||||
/// here, and here are some tags". An attacker can therefore emit noise — bounded
|
||||
/// by existing announce and connection rate limits — but cannot impersonate a
|
||||
/// specific peer, because forging a recognition tag needs one of the two private
|
||||
/// keys, and cannot send anything without completing a handshake. The intended
|
||||
/// posture is to treat a v2 announce as *unverified presence* and not surface it
|
||||
/// until a tag matches or a handshake completes. See open question O4 in
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet.
|
||||
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.
|
||||
public let epoch: UInt32
|
||||
/// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes.
|
||||
public let tagBlock: Data
|
||||
public let capabilities: PeerCapabilities?
|
||||
/// Coarse rendezvous cell, when bridging. Same semantics as v1.
|
||||
public let bridgeGeohash: String?
|
||||
|
||||
public init(
|
||||
epoch: UInt32,
|
||||
tagBlock: Data,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) {
|
||||
self.epoch = epoch
|
||||
self.tagBlock = tagBlock
|
||||
self.capabilities = capabilities
|
||||
self.bridgeGeohash = bridgeGeohash
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case epoch = 0x01
|
||||
case tagBlock = 0x02
|
||||
case capabilities = 0x03
|
||||
case bridgeGeohash = 0x04
|
||||
}
|
||||
|
||||
/// Expected tag-block width. A fixed size is load-bearing: it hides how many
|
||||
/// mutual favourites a device has.
|
||||
public static var tagBlockLength: Int {
|
||||
PeerIDRotation.tagSlots * PeerIDRotation.idLength
|
||||
}
|
||||
|
||||
public func encode() -> Data? {
|
||||
guard tagBlock.count == Self.tagBlockLength else { return nil }
|
||||
|
||||
var data = Data()
|
||||
|
||||
data.append(TLVType.epoch.rawValue)
|
||||
data.append(UInt8(4))
|
||||
withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) }
|
||||
|
||||
data.append(TLVType.tagBlock.rawValue)
|
||||
data.append(UInt8(tagBlock.count))
|
||||
data.append(tagBlock)
|
||||
|
||||
if let capabilities {
|
||||
let bytes = capabilities.encoded()
|
||||
guard bytes.count <= 255 else { return nil }
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(bytes.count))
|
||||
data.append(bytes)
|
||||
}
|
||||
|
||||
if let bridgeGeohash, !bridgeGeohash.isEmpty {
|
||||
let bytes = Data(bridgeGeohash.utf8)
|
||||
guard bytes.count <= 12 else { return nil }
|
||||
data.append(TLVType.bridgeGeohash.rawValue)
|
||||
data.append(UInt8(bytes.count))
|
||||
data.append(bytes)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
public static func decode(from data: Data) -> AnnounceV2Packet? {
|
||||
var epoch: UInt32?
|
||||
var tagBlock: Data?
|
||||
var capabilities: PeerCapabilities?
|
||||
var bridgeGeohash: String?
|
||||
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil }
|
||||
let rawType = data[offset]
|
||||
let length = Int(data[data.index(after: offset)])
|
||||
let valueStart = data.index(offset, offsetBy: 2)
|
||||
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
|
||||
let value = data.subdata(in: valueStart..<data.index(valueStart, offsetBy: length))
|
||||
|
||||
switch TLVType(rawValue: rawType) {
|
||||
case .epoch:
|
||||
guard length == 4 else { return nil }
|
||||
epoch = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
case .tagBlock:
|
||||
guard length == tagBlockLength else { return nil }
|
||||
tagBlock = value
|
||||
case .capabilities:
|
||||
let decoded = PeerCapabilities(encoded: value)
|
||||
// Canonicality check, matching AuthenticatedPeerStatePacket: a
|
||||
// non-minimal encoding would let the same capability set travel
|
||||
// as different bytes.
|
||||
guard decoded.encoded() == value else { return nil }
|
||||
capabilities = decoded
|
||||
case .bridgeGeohash:
|
||||
guard length <= 12, let text = String(data: value, encoding: .utf8) else { return nil }
|
||||
bridgeGeohash = text
|
||||
case nil:
|
||||
// Unknown TLV: skip, for forward compatibility.
|
||||
break
|
||||
}
|
||||
|
||||
offset = data.index(valueStart, offsetBy: length)
|
||||
}
|
||||
|
||||
guard let epoch, let tagBlock else { return nil }
|
||||
return AnnounceV2Packet(
|
||||
epoch: epoch,
|
||||
tagBlock: tagBlock,
|
||||
capabilities: capabilities,
|
||||
bridgeGeohash: bridgeGeohash
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -40,27 +40,9 @@ public enum MessageType: UInt8 {
|
||||
// never gossip-synced). Private bursts ride noiseEncrypted instead.
|
||||
case voiceFrame = 0x29
|
||||
|
||||
/// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed
|
||||
/// block of pairwise recognition tags, and capabilities — no nickname, no
|
||||
/// public keys, no neighbour list. A separate type rather than a version of
|
||||
/// `announce` because that decoder hard-requires the identity TLVs, so
|
||||
/// omitting them is a parse failure rather than a graceful degrade.
|
||||
///
|
||||
/// `0x2C`, not the seemingly-free `0x05`: that value has been recycled
|
||||
/// twice already (`announce`, then `bulkTransferResponse`, then
|
||||
/// `fragmentStart` until #446), and a very old peer that still maps it to a
|
||||
/// fragment header would misparse presence as a partial message. Values
|
||||
/// after `voiceFrame` have only ever been allocated forward. `0x2A`/`0x2B`
|
||||
/// are spoken for by the courier spray-ack work, hence `0x2C`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet; see
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
case announceV2 = 0x2C
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .announce: return "announce"
|
||||
case .announceV2: return "announceV2"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .courierEnvelope: return "courierEnvelope"
|
||||
|
||||
@ -1,315 +0,0 @@
|
||||
//
|
||||
// PeerIDRotation.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
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
|
||||
/// open questions. This type is the executable half of that document: it is
|
||||
/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE
|
||||
/// stack) so both platforms can agree on the numbers before anyone wires it
|
||||
/// into a transport.
|
||||
///
|
||||
/// Nothing here is used by the shipping mesh yet.
|
||||
///
|
||||
/// ## Why the derivations look like this
|
||||
///
|
||||
/// The rotating ID comes from **private** key material. Deriving it from the
|
||||
/// public key would let anyone who has ever seen that key compute every past
|
||||
/// and future ID, which is worse than not rotating because it would look like
|
||||
/// protection. The same mistake is live in `CourierEnvelope.recipientTag`,
|
||||
/// which is keyed on the recipient's *public* static key — and since that key
|
||||
/// is broadcast in cleartext in every announce today, any observer in radio
|
||||
/// range can compute a peer's courier tags for any day.
|
||||
///
|
||||
/// 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.
|
||||
public enum PeerIDRotation {
|
||||
// MARK: - Parameters
|
||||
|
||||
/// Seconds per rotation epoch. One hour is a starting position, not a
|
||||
/// settled one: shorter is less linkable but churns sessions, routes, and
|
||||
/// in-flight fragment reassembly more often. See open question O1.
|
||||
public static let rotationPeriod: TimeInterval = 3600
|
||||
|
||||
/// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte
|
||||
/// header sender ID, so the packet layout is unchanged.
|
||||
public static let idLength = 8
|
||||
|
||||
/// Fixed number of tag slots in an announce. Padding to a constant hides
|
||||
/// how many mutual favourites a device has, which is itself identifying.
|
||||
public static let tagSlots = 8
|
||||
|
||||
// MARK: - Context strings
|
||||
//
|
||||
// Distinct per use so a value derived for one purpose can never be
|
||||
// substituted for another. `bitchat-announce-v1` is deliberately NOT reused:
|
||||
// it belongs to the production-dead announce-signature helpers in
|
||||
// NoiseEncryptionService, and confusing the two would be a real bug.
|
||||
|
||||
private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8)
|
||||
private static let peerIDContext = Data("bitchat-peer-id-v2".utf8)
|
||||
private static let recognitionInfo = Data("bitchat-recognition-v1".utf8)
|
||||
private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8)
|
||||
|
||||
// MARK: - Epochs
|
||||
|
||||
/// Epoch number for a point in time. Wall-clock derived so two devices that
|
||||
/// have never met agree on the current epoch without negotiating.
|
||||
public static func epoch(at date: Date) -> UInt32 {
|
||||
let seconds = max(0, date.timeIntervalSince1970)
|
||||
return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod))
|
||||
}
|
||||
|
||||
/// Epochs to test when matching, oldest first.
|
||||
///
|
||||
/// The ±1 window absorbs clock skew and the moment either side crosses a
|
||||
/// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two
|
||||
/// devices a few seconds apart across a boundary would fail to recognise
|
||||
/// each other for no reason a person could understand.
|
||||
public static func candidateEpochs(around date: Date) -> [UInt32] {
|
||||
let current = epoch(at: date)
|
||||
return current == 0 ? [0, 1] : [current - 1, current, current + 1]
|
||||
}
|
||||
|
||||
// MARK: - Rotating peer ID
|
||||
|
||||
/// Long-lived rotation secret for this device. Derived from the Noise
|
||||
/// static **private** key, so it never leaves the device and no observer
|
||||
/// can predict any ID it produces.
|
||||
public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data {
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey),
|
||||
info: rotationInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
/// This device's peer ID for a given epoch.
|
||||
public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data {
|
||||
var message = peerIDContext
|
||||
message.append(bigEndianBytes(epoch))
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: message,
|
||||
using: SymmetricKey(data: rotationSecret)
|
||||
)
|
||||
return Data(mac).prefix(idLength)
|
||||
}
|
||||
|
||||
/// Convenience: the ID this device should be using at `date`.
|
||||
public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data {
|
||||
peerID(
|
||||
rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey),
|
||||
epoch: epoch(at: date)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Pairwise recognition tags
|
||||
|
||||
/// Symmetric recognition key for a pair, from their X25519 shared secret.
|
||||
///
|
||||
/// Both sides compute the identical value from opposite key halves, which
|
||||
/// is the whole point: recognition needs no round trip, and no third party
|
||||
/// can derive it.
|
||||
public static func recognitionKey(sharedSecret: Data) -> Data {
|
||||
let derived = HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecret),
|
||||
info: recognitionInfo,
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derived.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
/// The tag `sender` puts in its announce for `recipient` this epoch.
|
||||
///
|
||||
/// Three inputs beyond the epoch, each load-bearing:
|
||||
///
|
||||
/// - **Ordered keys make the tag directional.** An earlier draft used
|
||||
/// `HMAC(K_AB, epoch)`, which is symmetric — so A and B broadcast the
|
||||
/// *same* 8 bytes, and an observer who spots one value in two different
|
||||
/// announces learns those two devices are mutual favourites and can link
|
||||
/// their rotating IDs to each other. That hands over exactly the social
|
||||
/// graph this design exists to hide. Ordering the keys gives A→B and B→A
|
||||
/// distinct values; both parties can still compute both directions,
|
||||
/// because both hold both public keys.
|
||||
/// - **`peerID` binds the tag to the announce carrying it.** Without it the
|
||||
/// tag depends only on (pair, epoch), so an attacker could lift a tag out
|
||||
/// of A's announce and replay it under an ID of their choosing; the
|
||||
/// recipient would match and believe that ID is A. Binding means a lifted
|
||||
/// tag is only valid alongside A's own ID, which reduces the attack from
|
||||
/// impersonation-as-any-ID to replaying A's presence.
|
||||
///
|
||||
/// Replaying A's own announce within the epoch window remains possible —
|
||||
/// unsigned announces cannot prevent it. Recognition is therefore a hint
|
||||
/// only, and anything consequential must wait for a completed handshake.
|
||||
/// See open question O4.
|
||||
public static func recognitionTag(
|
||||
recognitionKey: Data,
|
||||
epoch: UInt32,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: Data
|
||||
) -> Data {
|
||||
var message = bigEndianBytes(epoch)
|
||||
message.append(fixedWidth(senderStaticPublicKey, 32))
|
||||
message.append(fixedWidth(recipientStaticPublicKey, 32))
|
||||
message.append(fixedWidth(peerID, idLength))
|
||||
let mac = HMAC<SHA256>.authenticationCode(
|
||||
for: message,
|
||||
using: SymmetricKey(data: recognitionKey)
|
||||
)
|
||||
return Data(mac).prefix(idLength)
|
||||
}
|
||||
|
||||
// MARK: - Tag block
|
||||
|
||||
/// Packs tags into the fixed-size announce block, padding with uniform
|
||||
/// random bytes.
|
||||
///
|
||||
/// Random padding is indistinguishable from a real tag to anyone who cannot
|
||||
/// compute the real ones, so the block discloses neither how many mutual
|
||||
/// favourites a device has nor which slot belongs to whom. Tags beyond
|
||||
/// `tagSlots` are dropped here; choosing *which* to carry across successive
|
||||
/// announces is the caller's problem (open question O2).
|
||||
public static func tagBlock(
|
||||
tags: [Data],
|
||||
randomBytes: (Int) -> Data = Self.secureRandomBytes
|
||||
) -> Data {
|
||||
var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) }
|
||||
// Order must carry no information, so shuffle rather than appending
|
||||
// real tags at the front.
|
||||
slots.shuffle()
|
||||
var block = Data()
|
||||
for slot in slots {
|
||||
block.append(slot)
|
||||
if slot.count < idLength {
|
||||
block.append(Data(repeating: 0, count: idLength - slot.count))
|
||||
}
|
||||
}
|
||||
let padding = (tagSlots - slots.count) * idLength
|
||||
if padding > 0 {
|
||||
block.append(randomBytes(padding))
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
/// Splits a received block back into candidate tags.
|
||||
///
|
||||
/// Returns nil for a block that is not exactly `tagSlots * idLength`, so a
|
||||
/// malformed announce is rejected rather than partially interpreted.
|
||||
public static func tags(fromBlock block: Data) -> [Data]? {
|
||||
guard block.count == tagSlots * idLength else { return nil }
|
||||
return stride(from: 0, to: block.count, by: idLength).map {
|
||||
block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any slot in `block` holds the tag we expect a specific peer to
|
||||
/// have put there, for an announce carrying `peerID`.
|
||||
///
|
||||
/// `senderStaticPublicKey` is the peer we hope sent this (so we compute the
|
||||
/// direction they would use) and `recipientStaticPublicKey` is our own.
|
||||
/// Passing them the other way round tests the opposite direction and will
|
||||
/// not match, which is the point of making tags directional.
|
||||
///
|
||||
/// Comparison is constant-time per candidate, and every slot is examined
|
||||
/// even after a match, so neither the presence of a match nor its slot
|
||||
/// index is observable through timing.
|
||||
public static func blockMatches(
|
||||
_ block: Data,
|
||||
recognitionKey: Data,
|
||||
senderStaticPublicKey: Data,
|
||||
recipientStaticPublicKey: Data,
|
||||
peerID: Data,
|
||||
at date: Date
|
||||
) -> Bool {
|
||||
guard let slots = tags(fromBlock: block) else { return false }
|
||||
let expected = candidateEpochs(around: date).map {
|
||||
recognitionTag(
|
||||
recognitionKey: recognitionKey,
|
||||
epoch: $0,
|
||||
senderStaticPublicKey: senderStaticPublicKey,
|
||||
recipientStaticPublicKey: recipientStaticPublicKey,
|
||||
peerID: peerID
|
||||
)
|
||||
}
|
||||
var matched = false
|
||||
for slot in slots {
|
||||
for candidate in expected where constantTimeEquals(slot, candidate) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
// MARK: - Identity binding
|
||||
|
||||
/// Canonical bytes proving a rotating ID belongs to a static key.
|
||||
///
|
||||
/// Signed with the Ed25519 identity key and exchanged **inside** a
|
||||
/// completed Noise session, this replaces the derivation check that today
|
||||
/// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked
|
||||
/// in the announce preflight and again at handshake completion). Once IDs
|
||||
/// are independent of the key, those checks fail for every peer, so a
|
||||
/// replacement has to exist before rotation can ship.
|
||||
///
|
||||
/// Fixed-width fields throughout: no length prefixes are needed and no two
|
||||
/// distinct inputs can produce the same bytes.
|
||||
public static func bindingMessage(
|
||||
epoch: UInt32,
|
||||
peerID: Data,
|
||||
noiseStaticPublicKey: Data
|
||||
) -> Data {
|
||||
var out = bindingContext
|
||||
out.append(bigEndianBytes(epoch))
|
||||
out.append(fixedWidth(peerID, idLength))
|
||||
out.append(fixedWidth(noiseStaticPublicKey, 32))
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Padding must be indistinguishable from a real tag, so it comes from the
|
||||
/// system CSPRNG via key generation rather than a general-purpose RNG.
|
||||
public static func secureRandomBytes(_ count: Int) -> Data {
|
||||
guard count > 0 else { return Data() }
|
||||
let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8))
|
||||
return key.withUnsafeBytes { Data($0) }
|
||||
}
|
||||
|
||||
private static func bigEndianBytes(_ value: UInt32) -> Data {
|
||||
withUnsafeBytes(of: value.bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
private static func fixedWidth(_ data: Data, _ width: Int) -> Data {
|
||||
var out = data.prefix(width)
|
||||
if out.count < width {
|
||||
out.append(Data(repeating: 0, count: width - out.count))
|
||||
}
|
||||
return Data(out)
|
||||
}
|
||||
|
||||
/// Length-independent comparison, so a match cannot be found byte by byte
|
||||
/// through timing.
|
||||
private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
|
||||
guard lhs.count == rhs.count else { return false }
|
||||
var difference: UInt8 = 0
|
||||
for (left, right) in zip(lhs, rhs) {
|
||||
difference |= left ^ right
|
||||
}
|
||||
return difference == 0
|
||||
}
|
||||
}
|
||||
@ -1,159 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import BitFoundation
|
||||
|
||||
/// Wire-format tests for the identity-free announce. These are the second half
|
||||
/// of the cross-platform contract: Android must encode and decode byte-identical
|
||||
/// packets, so anything asserted here is a promise, not an implementation detail.
|
||||
struct AnnounceV2PacketTests {
|
||||
private var block: Data {
|
||||
Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength)
|
||||
}
|
||||
|
||||
@Test func typeValueIsStable() {
|
||||
// Changing this breaks every deployed decoder.
|
||||
//
|
||||
// Deliberately NOT 0x05, which merely looks free: it has been recycled
|
||||
// twice already (announce, then bulkTransferResponse, then fragmentStart
|
||||
// until #446), so an old peer could still map it to a fragment header
|
||||
// and misparse presence as a partial message. Values above
|
||||
// voiceFrame = 0x29 have only ever been allocated forward; 0x2A/0x2B
|
||||
// belong to the courier spray-ack work.
|
||||
#expect(MessageType.announceV2.rawValue == 0x2C)
|
||||
#expect(MessageType(rawValue: 0x2C) == .announceV2)
|
||||
#expect(MessageType.announceV2.description == "announceV2")
|
||||
}
|
||||
|
||||
@Test func tagBlockIsSixtyFourBytes() {
|
||||
#expect(AnnounceV2Packet.tagBlockLength == 64)
|
||||
}
|
||||
|
||||
@Test func roundTripsWithEveryField() throws {
|
||||
let packet = AnnounceV2Packet(
|
||||
epoch: 495_555,
|
||||
tagBlock: block,
|
||||
capabilities: [.bridge, .prekeys],
|
||||
bridgeGeohash: "u4pruy"
|
||||
)
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
|
||||
#expect(decoded == packet)
|
||||
}
|
||||
|
||||
@Test func roundTripsWithOnlyRequiredFields() throws {
|
||||
let packet = AnnounceV2Packet(epoch: 0, tagBlock: block)
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
|
||||
#expect(decoded == packet)
|
||||
#expect(decoded.capabilities == nil)
|
||||
#expect(decoded.bridgeGeohash == nil)
|
||||
}
|
||||
|
||||
@Test func epochIsBigEndianOnTheWire() throws {
|
||||
let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode())
|
||||
// TLV 0x01, length 4, then the epoch most-significant byte first.
|
||||
#expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04])
|
||||
}
|
||||
|
||||
/// The whole point of the format: none of the identifying v1 fields appear.
|
||||
@Test func encodingCarriesNoIdentity() throws {
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let signingKey = Data(repeating: 0x22, count: 32)
|
||||
let nickname = Data("alice".utf8)
|
||||
|
||||
let encoded = try #require(
|
||||
AnnounceV2Packet(
|
||||
epoch: 100,
|
||||
tagBlock: block,
|
||||
capabilities: [.bridge],
|
||||
bridgeGeohash: "u4pruy"
|
||||
).encode()
|
||||
)
|
||||
|
||||
#expect(!encoded.contains(noiseKey))
|
||||
#expect(!encoded.contains(signingKey))
|
||||
#expect(encoded.range(of: nickname) == nil)
|
||||
}
|
||||
|
||||
@Test func encodingIsSmallerThanAV1Announce() throws {
|
||||
let v2 = try #require(
|
||||
AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode()
|
||||
)
|
||||
// v1 with a 10-byte nickname and a full neighbour list, before its
|
||||
// 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82
|
||||
// + capabilities 3.
|
||||
let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3
|
||||
#expect(v2.count < v1PayloadEstimate)
|
||||
}
|
||||
|
||||
// MARK: - Rejection
|
||||
|
||||
@Test func encodeRejectsAWrongWidthTagBlock() {
|
||||
// A short block would disclose the favourite count, so it must never go
|
||||
// on the wire.
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil)
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil)
|
||||
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func encodeRejectsAnOversizedGeohash() {
|
||||
#expect(AnnounceV2Packet(
|
||||
epoch: 1,
|
||||
tagBlock: block,
|
||||
bridgeGeohash: String(repeating: "u", count: 13)
|
||||
).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRequiresEpochAndTagBlock() throws {
|
||||
// Capabilities alone is not a valid announce.
|
||||
var onlyCapabilities = Data([0x03, 0x01])
|
||||
onlyCapabilities.append(PeerCapabilities([.bridge]).encoded())
|
||||
#expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil)
|
||||
|
||||
// Epoch without a tag block is not either.
|
||||
let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
#expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsTruncatedAndMalformedInput() {
|
||||
#expect(AnnounceV2Packet.decode(from: Data()) == nil)
|
||||
// Declares 4 bytes, supplies 2.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil)
|
||||
// Dangling type byte with no length.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil)
|
||||
// Wrong epoch width.
|
||||
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsAWrongWidthTagBlock() {
|
||||
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
data.append(0x02)
|
||||
data.append(UInt8(63))
|
||||
data.append(Data(repeating: 0xAB, count: 63))
|
||||
#expect(AnnounceV2Packet.decode(from: data) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsNonCanonicalCapabilities() throws {
|
||||
// Same capability set, non-minimal encoding: it must not be accepted, or
|
||||
// one set could travel as several distinct byte strings.
|
||||
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
|
||||
data.append(0x02)
|
||||
data.append(UInt8(AnnounceV2Packet.tagBlockLength))
|
||||
data.append(block)
|
||||
data.append(0x03)
|
||||
data.append(UInt8(3))
|
||||
data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal
|
||||
#expect(AnnounceV2Packet.decode(from: data) == nil)
|
||||
}
|
||||
|
||||
@Test func unknownTLVsAreSkippedForForwardCompatibility() throws {
|
||||
var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode())
|
||||
data.append(0x7F) // a type this build has never heard of
|
||||
data.append(UInt8(3))
|
||||
data.append(Data([0x01, 0x02, 0x03]))
|
||||
|
||||
let decoded = try #require(AnnounceV2Packet.decode(from: data))
|
||||
#expect(decoded.epoch == 100)
|
||||
#expect(decoded.tagBlock == block)
|
||||
}
|
||||
}
|
||||
@ -1,408 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import CryptoKit
|
||||
@testable import BitFoundation
|
||||
|
||||
/// Executable test vectors for peer ID rotation.
|
||||
///
|
||||
/// These are the numbers the Android implementation must reproduce. Two rules
|
||||
/// for keeping them useful:
|
||||
///
|
||||
/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.**
|
||||
/// Deriving the expected values by reading the other platform's
|
||||
/// implementation proves only that both share a bug.
|
||||
/// 2. **If a derivation changes, the hex here changes too, deliberately.** A
|
||||
/// vector that gets "fixed" to match new behavior has stopped being a vector.
|
||||
///
|
||||
/// The three `VECTOR:` values below were cross-checked against an independent
|
||||
/// HKDF/HMAC implementation written from the specification alone (Python
|
||||
/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte.
|
||||
/// So the spec text is sufficient to reproduce them without reading this code —
|
||||
/// which is the property Android needs.
|
||||
struct PeerIDRotationTests {
|
||||
// A fixed, obviously-fake private key so the vectors are stable.
|
||||
private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20
|
||||
private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf
|
||||
|
||||
private func hex(_ data: Data) -> String {
|
||||
data.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
// MARK: - Epochs
|
||||
|
||||
@Test func epochIsWallClockDivision() {
|
||||
#expect(PeerIDRotation.rotationPeriod == 3600)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0)
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1)
|
||||
// 2026-07-26T00:00:00Z
|
||||
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555)
|
||||
}
|
||||
|
||||
@Test func candidateEpochsCoverTheBoundaryBothWays() {
|
||||
// Two devices seconds apart across a boundary must still recognise each
|
||||
// other, so the window spans the neighbouring epochs.
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
#expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101])
|
||||
}
|
||||
|
||||
@Test func candidateEpochsDoNotUnderflowAtTheOrigin() {
|
||||
// UInt32 underflow here would produce 4294967295 and break matching.
|
||||
#expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1])
|
||||
}
|
||||
|
||||
// MARK: - Rotating peer ID
|
||||
|
||||
@Test func rotationSecretIsStableForAKey() {
|
||||
let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
#expect(first == second)
|
||||
#expect(first.count == 32)
|
||||
// VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32)
|
||||
#expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09")
|
||||
}
|
||||
|
||||
@Test func peerIDIsEightBytesAndEpochDependent() {
|
||||
let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)
|
||||
let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101)
|
||||
|
||||
#expect(a.count == PeerIDRotation.idLength)
|
||||
#expect(b.count == PeerIDRotation.idLength)
|
||||
// VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8]
|
||||
#expect(hex(a) == "f7c08c528506a374")
|
||||
// The whole point: consecutive epochs are unrelated to an observer.
|
||||
#expect(a != b)
|
||||
// Deterministic within an epoch, so a restart keeps the same ID.
|
||||
#expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100))
|
||||
}
|
||||
|
||||
@Test func peerIDDiffersBetweenDevices() {
|
||||
let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
|
||||
let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB)
|
||||
#expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100)
|
||||
!= PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100))
|
||||
}
|
||||
|
||||
@Test func currentPeerIDMatchesTheExplicitEpochForm() {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100 + 17)
|
||||
let viaConvenience = PeerIDRotation.currentPeerID(
|
||||
noiseStaticPrivateKey: staticPrivateA,
|
||||
at: date
|
||||
)
|
||||
let viaParts = PeerIDRotation.peerID(
|
||||
rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA),
|
||||
epoch: 100
|
||||
)
|
||||
#expect(viaConvenience == viaParts)
|
||||
}
|
||||
|
||||
// MARK: - Recognition tags
|
||||
|
||||
private var pubA: Data { Data(repeating: 0x0A, count: 32) }
|
||||
private var pubB: Data { Data(repeating: 0x0B, count: 32) }
|
||||
private var idA: Data { Data(repeating: 0xA1, count: 8) }
|
||||
|
||||
/// The property that makes handshake-free recognition possible: both sides
|
||||
/// reach the same tag from opposite halves of the key pair.
|
||||
@Test func bothSidesDeriveTheSameRecognitionTag() throws {
|
||||
let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA)
|
||||
let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB)
|
||||
|
||||
let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey)
|
||||
let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey)
|
||||
let rawA = sharedFromA.withUnsafeBytes { Data($0) }
|
||||
let rawB = sharedFromB.withUnsafeBytes { Data($0) }
|
||||
#expect(rawA == rawB)
|
||||
|
||||
let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA)
|
||||
let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB)
|
||||
#expect(keyA == keyB)
|
||||
|
||||
// A emits its A->B tag; B computes the same value to look for it.
|
||||
let emitted = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: keyA, epoch: 100,
|
||||
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
||||
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
||||
peerID: idA
|
||||
)
|
||||
let expected = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: keyB, epoch: 100,
|
||||
senderStaticPublicKey: privA.publicKey.rawRepresentation,
|
||||
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
|
||||
peerID: idA
|
||||
)
|
||||
#expect(emitted == expected)
|
||||
#expect(emitted.count == PeerIDRotation.idLength)
|
||||
}
|
||||
|
||||
/// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the
|
||||
/// identical 8 bytes, so an observer who sees one value in two announces
|
||||
/// learns those two are mutual favourites and can link their rotating IDs.
|
||||
/// Tags must therefore differ by direction.
|
||||
@Test func recognitionTagsAreDirectional() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let aToB = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let bToA = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA
|
||||
)
|
||||
#expect(aToB != bToA)
|
||||
}
|
||||
|
||||
/// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted
|
||||
/// from someone's announce could be replayed under an attacker-chosen ID and
|
||||
/// the recipient would accept that ID as the favourite.
|
||||
@Test func recognitionTagIsBoundToTheAnnouncedPeerID() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let real = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let underAttackerID = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: Data(repeating: 0xFF, count: 8)
|
||||
)
|
||||
#expect(real != underAttackerID)
|
||||
|
||||
// And the lifted tag must not verify against the attacker's ID.
|
||||
let block = PeerIDRotation.tagBlock(tags: [real])
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: Data(repeating: 0xFF, count: 8),
|
||||
at: Date(timeIntervalSince1970: 3600 * 100)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func recognitionTagRotatesWithTheEpoch() {
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
|
||||
let now = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 100,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
let next = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: 101,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
#expect(now != next)
|
||||
// VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"),
|
||||
// uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8]
|
||||
#expect(hex(now) == "4568f61d61d6cbfb")
|
||||
}
|
||||
|
||||
@Test func aThirdPartyCannotDeriveAPairsTag() {
|
||||
// An observer holding a *different* shared secret gets a different tag,
|
||||
// which is what stops it from tracking the pair.
|
||||
let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32))
|
||||
let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32))
|
||||
#expect(PeerIDRotation.recognitionTag(
|
||||
recognitionKey: pair, epoch: 7,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
) != PeerIDRotation.recognitionTag(
|
||||
recognitionKey: other, epoch: 7,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Tag block
|
||||
|
||||
@Test func tagBlockIsAlwaysFullWidth() {
|
||||
let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength
|
||||
for count in 0...PeerIDRotation.tagSlots {
|
||||
let tags = (0..<count).map { Data(repeating: UInt8($0 + 1), count: 8) }
|
||||
#expect(PeerIDRotation.tagBlock(tags: tags).count == expected)
|
||||
}
|
||||
}
|
||||
|
||||
/// A device with one favourite and a device with six must be
|
||||
/// indistinguishable from the block, or the block leaks social-graph size.
|
||||
@Test func tagBlockHidesHowManyFavouritesThereAre() {
|
||||
let one = PeerIDRotation.tagBlock(tags: [Data(repeating: 0xAA, count: 8)])
|
||||
let six = PeerIDRotation.tagBlock(
|
||||
tags: (1...6).map { Data(repeating: UInt8($0), count: 8) }
|
||||
)
|
||||
#expect(one.count == six.count)
|
||||
}
|
||||
|
||||
@Test func tagBlockDropsOverflowRatherThanGrowing() {
|
||||
let tags = (1...(PeerIDRotation.tagSlots + 5)).map { Data(repeating: UInt8($0), count: 8) }
|
||||
#expect(PeerIDRotation.tagBlock(tags: tags).count == PeerIDRotation.tagSlots * 8)
|
||||
}
|
||||
|
||||
@Test func padOnlyBlockUsesFreshRandomnessEachTime() {
|
||||
// Repeated identical padding would make an empty block recognisable.
|
||||
let first = PeerIDRotation.tagBlock(tags: [])
|
||||
let second = PeerIDRotation.tagBlock(tags: [])
|
||||
#expect(first != second)
|
||||
}
|
||||
|
||||
@Test func tagsRoundTripThroughTheBlock() throws {
|
||||
let real = Data(repeating: 0xC3, count: 8)
|
||||
let block = PeerIDRotation.tagBlock(
|
||||
tags: [real],
|
||||
randomBytes: { Data(repeating: 0x00, count: $0) }
|
||||
)
|
||||
let slots = try #require(PeerIDRotation.tags(fromBlock: block))
|
||||
#expect(slots.count == PeerIDRotation.tagSlots)
|
||||
#expect(slots.contains(real))
|
||||
}
|
||||
|
||||
@Test func malformedBlockIsRejectedRatherThanPartiallyRead() {
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data()) == nil)
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 7)) == nil)
|
||||
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 65)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Matching
|
||||
|
||||
private func matchFixture() -> (key: Data, tag: Data, date: Date) {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32))
|
||||
let tag = PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key,
|
||||
epoch: PeerIDRotation.epoch(at: date),
|
||||
senderStaticPublicKey: pubA,
|
||||
recipientStaticPublicKey: pubB,
|
||||
peerID: idA
|
||||
)
|
||||
return (key, tag, date)
|
||||
}
|
||||
|
||||
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
|
||||
let (key, tag, date) = matchFixture()
|
||||
// Slot order must not matter, so assert across many shuffles.
|
||||
for _ in 0..<20 {
|
||||
let block = PeerIDRotation.tagBlock(tags: [tag])
|
||||
#expect(PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Testing the wrong direction must fail, or the directional fix would be
|
||||
/// cosmetic.
|
||||
@Test func blockDoesNotMatchTheOppositeDirection() {
|
||||
let (key, tag, date) = matchFixture()
|
||||
let block = PeerIDRotation.tagBlock(tags: [tag])
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
block, recognitionKey: key,
|
||||
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
|
||||
@Test func blockMatchesToleratesTheEpochBoundary() {
|
||||
let date = Date(timeIntervalSince1970: 3600 * 100)
|
||||
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
|
||||
|
||||
func tag(epoch: UInt32) -> Data {
|
||||
PeerIDRotation.recognitionTag(
|
||||
recognitionKey: key, epoch: epoch,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
|
||||
)
|
||||
}
|
||||
func matches(_ candidate: Data) -> Bool {
|
||||
PeerIDRotation.blockMatches(
|
||||
PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
)
|
||||
}
|
||||
|
||||
// A peer whose clock has already ticked over still matches.
|
||||
#expect(matches(tag(epoch: 101)))
|
||||
// Two epochs out is outside the window and must not.
|
||||
#expect(!matches(tag(epoch: 98)))
|
||||
}
|
||||
|
||||
@Test func randomBlockDoesNotMatch() {
|
||||
let (key, _, date) = matchFixture()
|
||||
#expect(!PeerIDRotation.blockMatches(
|
||||
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
|
||||
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
|
||||
peerID: idA, at: date
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Identity binding
|
||||
|
||||
@Test func bindingMessageIsFixedWidthAndContextSeparated() {
|
||||
let message = PeerIDRotation.bindingMessage(
|
||||
epoch: 100,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
let context = Data("bitchat-peerid-binding-v1".utf8)
|
||||
#expect(message.count == context.count + 4 + 8 + 32)
|
||||
#expect(message.starts(with: context))
|
||||
// Must not collide with the production-dead announce-signature helpers,
|
||||
// which use "bitchat-announce-v1".
|
||||
#expect(!message.starts(with: Data("bitchat-announce-v1".utf8)))
|
||||
}
|
||||
|
||||
@Test func bindingMessagePadsShortInputsRatherThanShifting() {
|
||||
// Fixed-width fields mean a short ID cannot shift the key into the ID's
|
||||
// position and produce a message that verifies for the wrong pairing.
|
||||
let short = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data([0x01]),
|
||||
noiseStaticPublicKey: Data([0x02])
|
||||
)
|
||||
let padded = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data([0x01]) + Data(repeating: 0, count: 7),
|
||||
noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31)
|
||||
)
|
||||
#expect(short == padded)
|
||||
}
|
||||
|
||||
@Test func bindingMessageChangesWithEveryField() {
|
||||
let base = PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
)
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 2,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
))
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x03, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
|
||||
))
|
||||
#expect(base != PeerIDRotation.bindingMessage(
|
||||
epoch: 1,
|
||||
peerID: Data(repeating: 0x01, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0x04, count: 32)
|
||||
))
|
||||
}
|
||||
|
||||
@Test func bindingMessageVerifiesUnderTheIdentityKey() throws {
|
||||
let signing = Curve25519.Signing.PrivateKey()
|
||||
let message = PeerIDRotation.bindingMessage(
|
||||
epoch: 100,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
let signature = try signing.signature(for: message)
|
||||
#expect(signing.publicKey.isValidSignature(signature, for: message))
|
||||
|
||||
// A different epoch must not verify: replaying a binding into a later
|
||||
// epoch is exactly what this prevents.
|
||||
let other = PeerIDRotation.bindingMessage(
|
||||
epoch: 101,
|
||||
peerID: Data(repeating: 0xAB, count: 8),
|
||||
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
|
||||
)
|
||||
#expect(!signing.publicKey.isValidSignature(signature, for: other))
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user