Merge remote-tracking branch 'origin/main' into fix/1064-default-view

# Conflicts:
#	bitchat/App/AppChromeModel.swift
#	bitchat/Views/ContentView.swift
This commit is contained in:
ecgang 2026-08-26 09:50:59 -06:00
commit 3c6fafb860
63 changed files with 18812 additions and 499 deletions

View File

@ -11,9 +11,11 @@ jobs:
name: Periphery scan
runs-on: macos-latest
timeout-minutes: 30
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
# block merges. Drop continue-on-error once the baseline proves stable.
continue-on-error: true
# Blocking, like SwiftLint since #1646. The baseline (committed July 8)
# has been stable across a month of merges — the "drop continue-on-error
# once the baseline proves stable" condition this job shipped with is met.
# Known macOS-scan false positives stay suppressed by the baseline; only
# NEW dead code fails the job (--strict below).
steps:
- name: Checkout code

View File

@ -264,11 +264,13 @@ jobs:
CODE_SIGNING_ALLOWED=NO \
test
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
# Blocking since the backlog reached zero (all violations fixed; rules the
# codebase deliberately breaks are disabled in .swiftlint.yml). --strict
# promotes warnings to errors so new violations fail the job. Runs in a
# pinned container (no Xcode plugin, no pbxproj changes) so it can never
# break the documented xcodebuild path.
lint:
name: SwiftLint (advisory)
name: SwiftLint
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
@ -280,10 +282,9 @@ jobs:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
run: swiftlint lint --strict --reporter github-actions-logging

View File

@ -3,6 +3,7 @@
excluded:
- .build
- .claude
- .device-lab
- .swiftpm
- .DerivedData
- DerivedData
@ -21,6 +22,7 @@ disabled_rules:
- control_statement
- void_function_in_ternary
- redundant_discardable_let # SwiftUI breaks without it
- todo # TODOs that cite a tracked issue are deliberate markers, not lint debt
# To be enabled as we fix the issues
- trailing_whitespace
- cyclomatic_complexity

View File

@ -17,6 +17,9 @@ 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
/// Latch for the people / conversation-list sheet. Owned here (rather than as
/// `ContentView` local `@State`) so non-view launch code can raise it: on launch
@ -27,6 +30,12 @@ final class AppChromeModel: ObservableObject {
/// the launch presentation from colliding with the fingerprint / image-picker
/// sheets (#1064).
@Published var showSidebar = 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,7 +129,24 @@ 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()
@ -154,6 +180,14 @@ 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
}
}

View File

@ -636,16 +636,24 @@ private extension AppRuntime {
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
let isLocationChannelActive: Bool = {
if case .location = chatViewModel.activeChannel { return true }
return false
}()
switch Self.resolveScreenshotResponse(
isLocationChannelsSheetPresented: appChromeModel.isLocationChannelsSheetPresented,
isAppInfoPresented: appChromeModel.isAppInfoPresented,
hasPrivateChatOpen: chatViewModel.selectedPrivateChatPeer != nil,
isLocationChannelActive: isLocationChannelActive
) {
case .warnLocally:
appChromeModel.triggerScreenshotPrivacyWarning()
return
case .ignore:
break
case .forwardToChat:
chatViewModel.handleScreenshotCaptured()
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
@ -662,3 +670,40 @@ private extension AppRuntime {
}
}
}
// MARK: - Screenshot routing
extension AppRuntime {
/// What a screenshot triggers. Nothing on this table sends anything to
/// a public channel (see `ChatLifecycleCoordinator.handleScreenshotCaptured`).
enum ScreenshotCaptureResponse: Equatable {
/// Show the local location-privacy alert; nothing is sent anywhere.
case warnLocally
/// Do nothing. App Info holds no conversation or location content.
case ignore
/// Hand to the chat layer: a DM notice goes to the peer when a
/// secure session exists; public timelines stay silent. Mesh
/// deliberately gets no local alert either — a mesh screenshot
/// reveals no place and triggers no send, so there is nothing to
/// warn about, and alerting on every screenshot would train people
/// to dismiss the one alert that matters (the location one).
case forwardToChat
}
/// Pure decision table so the screenshot routing is testable without a
/// runtime.
nonisolated static func resolveScreenshotResponse(
isLocationChannelsSheetPresented: Bool,
isAppInfoPresented: Bool,
hasPrivateChatOpen: Bool,
isLocationChannelActive: Bool
) -> ScreenshotCaptureResponse {
if isLocationChannelsSheetPresented { return .warnLocally }
if isAppInfoPresented { return .ignore }
// A geohash timeline screenshot still reveals a place — warn the
// person taking it, locally, with the same alert the channel sheet
// uses.
if !hasPrivateChatOpen, isLocationChannelActive { return .warnLocally }
return .forwardToChat
}
}

View File

@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable {
var id: String { peerID.id }
}
/// A direct conversation whose person is NOT in any roster above it. Without
/// this section, a DM from a passerby became unreachable the moment they went
/// offline: the roster filters to connected/reachable/mutual-favorite, the
/// header envelope only exists while unread, and /msg can't resolve offline
/// strangers — the thread was still in memory with no row anywhere in the UI.
struct RecentChatRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let hasUnread: Bool
let lastActivity: Date
var id: String { peerID.id }
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var recentChatRows: [RecentChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
// Direct-conversation changes reorder or create recent-chat rows.
// Filtered to `.direct` so public-timeline traffic (every mesh or
// geohash message emits a change) doesn't rebuild the sheet.
conversations.changes
.receive(on: DispatchQueue.main)
.sink { [weak self] change in
guard case .direct = Self.changedConversationID(change) else { return }
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject {
let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject {
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
self.recentChatRows = recentChatRows
renderID = (
meshRows.map {
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
@ -255,10 +283,101 @@ final class PeerListModel: ObservableObject {
} +
groupRows.map {
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
} +
recentChatRows.map {
"chat:\($0.id)-\($0.displayName)-\($0.hasUnread)"
}
).joined(separator: "|")
}
/// Direct conversations with people absent from the rosters above:
/// the offline passerby DM, the geoDM from a channel since left. Rows
/// mirrored under both an ephemeral and a stable peer ID collapse to
/// one row per identity (newest activity wins), matching how the
/// private-chat coordinators consolidate on open.
private func buildRecentChatRows(
meshRows: [MeshPeerRow],
geohashPeople: [GeohashPersonRow]
) -> [RecentChatRow] {
// A conversation can be keyed by the stable Noise peer ID while the
// roster lists the ephemeral one — compare fingerprints too.
var visibleIdentities = Set<String>()
for row in meshRows {
visibleIdentities.insert(row.peerID.id)
if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) {
visibleIdentities.insert(fingerprint)
}
}
// Someone visible in the geohash section must not also get a chat
// row: their GeoDM conversation is keyed by the nostr_ form of the
// same pubkey the roster lists.
for person in geohashPeople {
visibleIdentities.insert(PeerID(nostr_: person.id).id)
}
struct Candidate {
let peerID: PeerID
let lastActivity: Date
}
var bestByIdentity: [String: Candidate] = [:]
for (id, conversation) in conversations.conversationsByID {
guard case .direct(let handle) = id else { continue }
let peerID = handle.routingPeerID
// Groups have their own section; blocked people get no row.
guard !peerID.isGroup else { continue }
guard let lastMessage = conversation.messages.last else { continue }
guard !chatViewModel.isPeerBlocked(peerID) else { continue }
let fingerprint = chatViewModel.getFingerprint(for: peerID)
if visibleIdentities.contains(peerID.id) { continue }
if let fingerprint, visibleIdentities.contains(fingerprint) { continue }
let identityKey = fingerprint ?? peerID.id
if let existing = bestByIdentity[identityKey],
existing.lastActivity >= lastMessage.timestamp {
continue
}
bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp)
}
return bestByIdentity.values
.sorted { $0.lastActivity > $1.lastActivity }
.map { candidate in
RecentChatRow(
peerID: candidate.peerID,
displayName: displayName(for: candidate.peerID),
hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID),
lastActivity: candidate.lastActivity
)
}
}
/// GeoDM conversations resolve through the Nostr mapping —
/// `resolveNickname` only consults mesh identity sources and would
/// render a `nostr_…` key as an opaque `anonnost` fallback.
private func displayName(for peerID: PeerID) -> String {
if peerID.isGeoDM {
return chatViewModel.geohashDisplayName(for: peerID)
}
return chatViewModel.resolveNickname(for: peerID)
}
private static func changedConversationID(_ change: ConversationChange) -> ConversationID {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id),
.removed(let id),
.unreadChanged(let id, _):
return id
case .migrated(_, let to):
return to
}
}
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in

View File

@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject {
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
favoriteStatus.isMutual,
// No stored recipient key means no Nostr delivery — and the
// "end-to-end encrypted" caption keys off .nostrAvailable.
favoriteStatus.peerNostrPublicKey != nil {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {

File diff suppressed because it is too large Load Diff

View File

@ -33,13 +33,22 @@ struct BitchatPeer: Equatable {
return .bluetoothConnected
} else if isReachable {
return .meshReachable
} else if favoriteStatus?.isMutual == true {
// Mutual favorites can communicate via Nostr when offline
} else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil {
// Mutual favorites can communicate via Nostr when offline — but
// only with a stored recipient key. NostrTransport applies the
// same rule when computing reachable peers; without a key,
// "available" would be a lie, and the DM header's
// "end-to-end encrypted" caption keys off this state.
return .nostrAvailable
} else {
return .offline
}
}
/// The Nostr key a private envelope would actually be sealed to, if any.
var reachableNostrPublicKey: String? {
nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey
}
var isFavorite: Bool {
favoriteStatus?.isFavorite ?? false

View File

@ -1001,8 +1001,7 @@ final class NoiseSessionManager {
0,
recentInitiatorCompletionGracePeriod - elapsed
)
let timeout = DispatchWorkItem(flags: .barrier) {
[weak self, weak establishedSession] in
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in
guard let self,
let establishedSession,
let current = self.sessions[peerID],
@ -1126,8 +1125,8 @@ final class NoiseSessionManager {
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
/// also accepted by internal callers when they exactly match the static
/// key. Non-wire identifiers remain available to protocol test harnesses;
/// BLE packet ingress always supplies a short hexadecimal ID.
/// key. Anything else fails closed: an identifier that can't be checked
/// against the remote static key must never complete a handshake.
private func authenticatedRemoteKey(
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID
@ -1139,7 +1138,7 @@ final class NoiseSessionManager {
if let claimedNoiseKey = claimedPeerID.noiseKey {
return claimedNoiseKey == rawKey
}
return true
return false
}
// MARK: - Encryption/Decryption

View File

@ -76,10 +76,9 @@ final class NostrIdentityBridge {
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
// CryptoKit key generation cannot fail, unlike SecRandomCopyBytes —
// a discarded failure here would persist an all-zero identity seed.
let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
// Ensure availability after first unlock to prevent unintended rotation when locked
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed

View File

@ -836,9 +836,12 @@ struct NostrEvent: Codable {
// Sign with Schnorr (BIP-340)
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
let auxStatus = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
guard auxStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()

View File

@ -4027,10 +4027,8 @@ extension BLEService {
)
}
}
service.onRekeyHandshakeReady = {
[weak self, weak service] peerID, initiation in
self?.messageQueue.async {
[weak self, weak service] in
service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in
self?.messageQueue.async { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
@ -4046,14 +4044,12 @@ extension BLEService {
self.broadcastNoiseHandshake(message, to: peerID)
}
}
service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in
service.onHandshakeRecoveryRequired = { [weak self, weak service] request in
guard let self, let service else { return }
#if DEBUG
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
#endif
self.messageQueue.async {
[weak self, weak service] in
self.messageQueue.async { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service else {
@ -5294,8 +5290,7 @@ extension BLEService {
) else {
return
}
messageQueue.async {
[weak self, weak service] in
messageQueue.async { [weak self, weak service] in
guard let self,
let service,
self.noiseService === service,

View File

@ -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: "Invalid command") }
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")) }
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: "groups are only for mesh peers in #mesh") }
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")) }
return handleGroup(args)
case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
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")) }
return handleFavorite(args, add: true)
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
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")) }
return handleFavorite(args, add: false)
case "/ping":
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
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")) }
return handlePing(args)
case "/trace":
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
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")) }
return handleTrace(args)
case "/pay":
return handlePay(args)
@ -154,31 +154,16 @@ final class CommandProcessor {
case "/help":
return .success(message: Self.helpText)
default:
return .error(message: "unknown command: \(cmd) — type /help for commands")
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)))
}
}
/// 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 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
"""
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")
}
/// /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
@ -186,28 +171,28 @@ final class CommandProcessor {
/// reads it.
private func handleDrop(_ args: String) -> CommandResult {
guard LocationNotesSettings.enabled else {
return .error(message: "location notes are off — enable them in the info screen")
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"))
}
guard let content = args.trimmedOrNilIfEmpty else {
return .error(message: "usage: /drop <message>")
return .error(message: String(localized: "command.drop.usage", defaultValue: "usage: /drop <message>", comment: "Usage hint for /drop"))
}
let location = LocationChannelManager.shared
guard location.permissionState == .authorized else {
return .error(message: "leaving a note needs location — enable it in the info screen")
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"))
}
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
location.refreshChannels()
return .error(message: "still finding this place — try again in a moment")
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"))
}
guard let nickname = contextProvider?.nickname,
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
return .error(message: "no geo relays reachable — note not left")
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"))
}
// 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: "📍 note left here — it fades in 24h")
return .success(message: String(localized: "command.drop.left", defaultValue: "📍 note left here — it fades in 24h", comment: "Confirmation after /drop pins a note"))
}
// MARK: - Command Handlers
@ -215,14 +200,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: "usage: /msg @nickname [message]")
return .error(message: String(localized: "command.msg.usage", defaultValue: "usage: /msg @nickname [message]", comment: "Usage hint for /msg"))
}
let targetName = String(parts[0])
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
return .error(message: "'\(nickname)' not found")
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))
}
contextProvider?.startPrivateChat(with: peerID)
@ -232,7 +217,7 @@ final class CommandProcessor {
contextProvider?.sendPrivateMessage(message, to: peerID)
}
return .success(message: "started private chat with \(nickname)")
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))
}
private func handleWho() -> CommandResult {
@ -240,22 +225,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: "nobody around") }
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")) }
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: "no one else is online right now") }
return .success(message: "online: " + names.sorted().joined(separator: ", "))
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: ", ")))
case .mesh:
// Mesh context: show connected peer nicknames
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
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"))
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
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))
}
}
@ -271,14 +256,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: "usage: /\(command) <nickname>")
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))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
let myNickname = contextProvider?.nickname else {
return .error(message: "cannot \(command) \(nickname): not found")
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))
}
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
@ -345,7 +330,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: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
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))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
@ -353,7 +338,7 @@ final class CommandProcessor {
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
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))
}
// Block the user (mesh/noise identity)
if var identity = identityManager.getSocialIdentity(for: fingerprint) {
@ -375,24 +360,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: "blocked \(nickname). you will no longer receive messages from them")
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))
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked")
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))
}
identityManager.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats")
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 .error(message: "cannot block \(nickname): not found or unable to verify identity")
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))
}
private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>")
return .error(message: String(localized: "command.unblock.usage", defaultValue: "usage: /unblock <nickname>", comment: "Usage hint for /unblock"))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
@ -400,23 +385,23 @@ final class CommandProcessor {
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if !identityManager.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
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))
}
identityManager.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)")
return .success(message: String(format: String(localized: "command.unblock.done", defaultValue: "unblocked %@", comment: "Confirmation after unblocking a mesh peer"), locale: .current, nickname))
}
// Try geohash unblock
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked")
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))
}
identityManager.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats")
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 .error(message: "cannot unblock \(nickname): not found")
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))
}
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
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 func handleGroup(_ args: String) -> CommandResult {
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
@ -454,12 +439,12 @@ final class CommandProcessor {
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .failed(.error(message: "usage: /\(command) <nickname>"))
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)))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
!peerID.isGeoDM, !peerID.isGeoChat else {
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
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 .resolved(peerID: peerID, nickname: nickname)
}
@ -488,7 +473,7 @@ final class CommandProcessor {
} ?? ""
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
}
return .success(message: "pinging \(nickname)…")
return .success(message: String(format: String(localized: "command.ping.started", defaultValue: "pinging %@…", comment: "Confirmation that /ping sent a probe"), locale: .current, nickname))
}
private func handleTrace(_ args: String) -> CommandResult {
@ -500,16 +485,20 @@ final class CommandProcessor {
guard let mesh = meshService,
let intermediates = meshDiagnostics?.computeMeshPath(to: target.peerID) else {
return .success(message: "no known path to \(target.nickname)")
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))
}
// 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 chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
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 hops = intermediates.count + 1
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
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)
}
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
@ -520,44 +509,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: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
return .success(message: String(localized: "command.pay.usage", defaultValue: "usage: /pay <token> — paste a cashu token: /pay cashuA…", comment: "Usage hint for /pay"))
}
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: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
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"))
}
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
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"))
}
let summary = info.displayAmount ?? "a cashu token"
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.sendPrivateMessage(token, to: peerID)
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
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))
}
guard confirmedPublic else {
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
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"))
}
contextProvider?.sendPublicMessage(token)
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
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))
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
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")))
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
return .error(message: "can't find peer: \(nickname)")
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))
}
// Resolve current state by the peer's real noise key. The resolved
@ -571,13 +560,17 @@ final class CommandProcessor {
}
guard add != isCurrentlyFavorite else {
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
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))
}
// toggleFavorite persists by the real noise key and notifies the peer.
contextProvider?.toggleFavorite(peerID: peerID)
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
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))
}
}

View File

@ -260,8 +260,10 @@ final class NotificationService {
}
func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
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)
// Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"

View File

@ -216,6 +216,10 @@ enum TransportConfig {
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Tolerated clock skew for the client-side rumor-timestamp window on
// inbound Nostr DMs (senders stamp the inner rumor with real time; only
// the outer gift wrap is randomized per NIP-17).
static let nostrDMMaxClockSkewSeconds: TimeInterval = 900
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900

View File

@ -84,7 +84,8 @@ final class VerificationService {
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
guard status == errSecSuccess else { return nil }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
@ -105,9 +106,10 @@ final class VerificationService {
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
// Freshness, in both directions: a future-dated timestamp must not
// buy a QR a longer validity window than a fresh one gets.
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
if abs(now - Double(qr.ts)) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let transport = transport else { return nil }

View File

@ -108,6 +108,6 @@ struct QuickJoinSuggestion {
"UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd",
"VE": "d9", "VI": "de", "VN": "w7", "VU": "rs",
"WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj",
"ZA": "ke", "ZM": "kt", "ZW": "ks",
"ZA": "ke", "ZM": "kt", "ZW": "ks"
]
}

View File

@ -14,17 +14,22 @@ 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 macPrivacyAnchor: String {
private var macURLString: String {
switch self {
case .bluetooth: "Privacy_Bluetooth"
case .location: "Privacy_LocationServices"
case .microphone: "Privacy_Microphone"
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"
}
}
#endif
@ -35,8 +40,7 @@ enum SystemSettings {
UIApplication.shared.open(url)
}
#elseif os(macOS)
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
if let url = URL(string: urlString) {
if let url = URL(string: macURLString) {
NSWorkspace.shared.open(url)
}
#endif

View File

@ -40,7 +40,6 @@ protocol ChatLifecycleContext: AnyObject {
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func addSystemMessage(_ content: String)
// MARK: Peers & sessions
func peerNickname(for peerID: PeerID) -> String?
@ -55,13 +54,10 @@ protocol ChatLifecycleContext: AnyObject {
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
@discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Nostr & geohash
var isTeleported: Bool { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func recordGeoParticipant(pubkeyHex: String)
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
/// The persisted favorite relationship for the peer's Noise static key, if any.
@ -80,10 +76,10 @@ extension ChatViewModel: ChatLifecycleContext {
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
// `markPrivateChatRead(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
// the routing/ack members,
// `deriveNostrIdentity(forGeohash:)`,
// and `favoriteRelationship(forNoiseKey:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
@ -143,34 +139,21 @@ final class ChatLifecycleCoordinator {
}
func handleScreenshotCaptured() {
// Public channels never announce screenshots. The old broadcast told
// everyone in radio range — and, on geohash channels, public Nostr
// relays, permanently — that this nickname was present and active
// here right now. Documenting something (or someone) is a core use
// of a protest app; it must not out the person doing it. Screenshot
// notices remain a DM-only courtesy between the two people involved.
guard let peerID = context.selectedPrivateChatPeer else { return }
let screenshotMessage = "* \(context.nickname) took a screenshot *"
if let peerID = context.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
)
// Only echo "you took a screenshot" when the peer was actually
// notified — the unconditional echo used to imply a notice that
// frequently was never sent (no established session).
if sendPrivateScreenshotNotificationIfPossible(screenshotMessage, to: peerID) {
appendPrivateScreenshotNotice(for: peerID)
return
}
switch context.activeChannel {
case .mesh:
context.sendMeshMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
timestamp: Date()
)
case .location(let channel):
sendPublicGeohashScreenshotMessage(
screenshotMessage,
channel: channel
)
}
context.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
@ -293,8 +276,10 @@ final class ChatLifecycleCoordinator {
}
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = context.peerNickname(for: peerID) else { return }
/// Returns whether the notice actually went out, so the caller can keep
/// the local echo honest.
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) -> Bool {
guard let peerNickname = context.peerNickname(for: peerID) else { return false }
let sessionState = context.noiseSessionState(for: peerID)
switch sessionState {
@ -305,19 +290,21 @@ 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: "you took a screenshot",
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"),
timestamp: Date(),
isRelay: false,
originalSender: nil,
@ -329,37 +316,6 @@ private extension ChatLifecycleCoordinator {
context.appendPrivateMessage(notice, to: peerID)
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
do {
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
}
}
func deliveryStatusRank(_ status: DeliveryStatus) -> Int {
switch status {
case .notSentYet: return 0

View File

@ -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("Voice notes are only available in mesh chats.")
context.addSystemMessage(String(localized: "Voice notes are only available in mesh chats.", comment: "System message when a voice note is attempted outside 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("Images are only available in mesh chats.")
context.addSystemMessage(String(localized: "Images are only available in mesh chats.", comment: "System message when an image send is attempted outside mesh chats"))
return
}
@ -1859,8 +1859,7 @@ private extension ChatMediaTransferCoordinator {
TimeInterval(UInt64.max) / 1_000_000_000
) * 1_000_000_000
)
reconnectRetryExpiryTasks[messageID] = Task {
@MainActor [weak self] in
reconnectRetryExpiryTasks[messageID] = Task { @MainActor [weak self] in
if nanoseconds > 0 {
try? await Task.sleep(nanoseconds: nanoseconds)
}

View File

@ -442,7 +442,13 @@ final class ChatPeerIdentityCoordinator {
return cachedStatus
}
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
// The status must reflect the LIVE session, never history. The old
// mapping returned secured/verified for any peer whose fingerprint was
// ever persisted — so after a cold launch or a handshake FAILURE the
// DM header still showed a solid lock and the composer still claimed
// "end-to-end encrypted" with no secure session in existence. A
// remembered fingerprint changes what an established session upgrades
// to (verified vs secured); it must not conjure a lock on its own.
let sessionState = context.noiseSessionState(for: peerID)
let status: EncryptionStatus
@ -450,11 +456,11 @@ final class ChatPeerIdentityCoordinator {
case .established:
status = verifiedEncryptionStatus(for: peerID)
case .handshaking, .handshakeQueued:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
status = .noiseHandshaking
case .none:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
status = .noHandshake
case .failed:
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
status = .none
}
context.setCachedEncryptionStatus(status, for: peerID)

View File

@ -294,7 +294,8 @@ final class ChatVerificationCoordinator {
}
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
guard status == errSecSuccess else { return false }
var pending = PendingVerification(
noiseKeyHex: qr.noiseKeyHex,
signKeyHex: qr.signKeyHex,

View File

@ -367,6 +367,10 @@ 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?
@ -1801,6 +1805,13 @@ 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
}

View File

@ -40,6 +40,7 @@ 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)
@ -68,6 +69,7 @@ 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(
@ -86,6 +88,9 @@ 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
}
}
}

View File

@ -357,6 +357,13 @@ final class NostrInboundPipeline {
return
}
guard Self.isPlausibleRumorTimestamp(rumorTs) else {
if verbose {
SecureLogger.warning("GeoDM: dropping gift-wrap with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session)
}
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
@ -445,6 +452,11 @@ final class NostrInboundPipeline {
recipientIdentity: currentIdentity
)
guard Self.isPlausibleRumorTimestamp(rumorTimestamp) else {
SecureLogger.warning("Dropping Nostr DM with implausible rumor timestamp id=\(giftWrap.id.prefix(8))…", category: .session)
return
}
if content.hasPrefix("verify:") {
return
}
@ -542,6 +554,22 @@ final class NostrInboundPipeline {
}
}
extension NostrInboundPipeline {
/// Client-side mirror of the relay-side `since` filter on DM
/// subscriptions: a relay that ignores `since` — or replays archived
/// events — must not inject stale or future-dated DMs. The inner rumor
/// timestamp is the sender's true send time (only the outer gift wrap
/// is randomized per NIP-17), so the plausible window is the
/// subscription lookback plus tolerated clock skew on both ends.
/// Internal (not private) so tests can pin the window directly.
static func isPlausibleRumorTimestamp(_ ts: Int, now: Date = Date()) -> Bool {
let age = now.timeIntervalSince1970 - TimeInterval(ts)
return age >= -TransportConfig.nostrDMMaxClockSkewSeconds
&& age <= TransportConfig.nostrDMSubscribeLookbackSeconds
+ TransportConfig.nostrDMMaxClockSkewSeconds
}
}
private extension NostrInboundPipeline {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {

View File

@ -29,7 +29,7 @@ final class VoiceRecordingViewModel: ObservableObject {
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
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 .idle, .requestingPermission, .preparing, .recording: ""
}
}
@ -156,7 +156,7 @@ final class VoiceRecordingViewModel: ObservableObject {
}
}
activeSession = nil
state = .error(message: "Could not start recording.")
state = .error(message: String(localized: "voice.error.start_failed", defaultValue: "could not start recording.", comment: "Alert message when the recorder fails to start"))
}
}
}
@ -202,8 +202,8 @@ final class VoiceRecordingViewModel: ObservableObject {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
? 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")
)
return
}
@ -212,8 +212,8 @@ final class VoiceRecordingViewModel: ObservableObject {
guard generation == holdGeneration, state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
? 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")
)
}
}

View File

@ -0,0 +1,116 @@
//
// 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)
}
}

View File

@ -19,6 +19,10 @@ 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
@ -69,8 +73,12 @@ 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 {
peerListModel.connectedMeshPeerCount == 0 && peerListModel.reachableMeshPeerCount == 0
bluetoothAvailable
&& peerListModel.connectedMeshPeerCount == 0
&& peerListModel.reachableMeshPeerCount == 0
}
var body: some View {

View File

@ -0,0 +1,42 @@
//
// 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)
}
}

View File

@ -238,7 +238,7 @@ private extension ContentComposerView {
}
Spacer()
Button(action: voiceRecordingVM.cancel) {
Label("Cancel", systemImage: "xmark.circle")
Label(String(localized: "common.cancel", comment: "Cancel action in the voice recording HUD"), systemImage: "xmark.circle")
.labelStyle(.iconOnly)
.font(.bitchatSystem(size: 18))
.foregroundColor(.red)
@ -482,7 +482,7 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
/// list is up. Inactive monitors pass everything through.
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
guard isActive(),
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
event.modifierFlags.isDisjoint(with: [.command, .option, .control]) else {
return event
}

View File

@ -51,11 +51,20 @@ struct ContentHeaderView: View {
// cluster at priority 3 never gives up width.
.layoutPriority(2)
.onTapGesture(count: 3) {
appChromeModel.panicClearAllData()
// 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()
}
.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.)

View File

@ -183,6 +183,18 @@ 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)
@ -270,7 +282,7 @@ struct ContentPeopleSheetView: View {
}
}
#endif
.alert("Recording Error", isPresented: voiceAlertBinding, actions: {
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: voiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {
@ -285,7 +297,10 @@ struct ContentPeopleSheetView: View {
isPresented: bluetoothAlertBinding
) {
Button("content.alert.bluetooth_required.settings") {
SystemSettings.bluetooth.open()
// Powered-off needs the radio controls, not the privacy pane.
(appChromeModel.bluetoothState == .poweredOff
? SystemSettings.bluetoothPower
: SystemSettings.bluetooth).open()
}
Button("common.ok", role: .cancel) {}
} message: {
@ -365,6 +380,16 @@ private struct ContentPeopleListView: View {
showSidebar = true
}
)
// Direct conversations survive channel switches; the
// geoDM someone opened from another cell must stay
// reachable here too.
RecentChatList(
chats: peerListModel.recentChatRows,
onTapChat: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
} else {
PeopleSectionHeader(
icon: "antenna.radiowaves.left.and.right",
@ -400,6 +425,16 @@ private struct ContentPeopleListView: View {
showSidebar = true
}
)
// Conversations with people no roster above lists
// anymore — without this, a read DM from an offline
// non-favorite had no row anywhere in the UI.
RecentChatList(
chats: peerListModel.recentChatRows,
onTapChat: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
}
)
}
}
.padding(.top, 4)
@ -615,7 +650,9 @@ private struct ContentPrivateChatSheetView: View {
// Geohash DMs use BitChat's private-envelope encryption over Nostr —
// always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
// "encrypted" claim only once the Noise handshake has secured — or
// when the peer is reachable only over Nostr, where delivery is
// gift-wrapped end-to-end without a Noise session.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let noiseSecured: Bool = {
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
@ -623,7 +660,8 @@ private struct ContentPrivateChatSheetView: View {
default: return false
}
}()
if isGeoDM || noiseSecured {
let nostrTransport = privateConversationModel.selectedHeaderState?.availability == .nostrAvailable
if isGeoDM || noiseSecured || nostrTransport {
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
}
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")

View File

@ -398,7 +398,7 @@ struct ContentView: View {
ImagePreviewView(url: url)
}
}
.alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: {
.alert(Text(String(localized: "voice.error.title", defaultValue: "recording error", comment: "Title of the voice recording error alert")), isPresented: rootVoiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {
@ -410,7 +410,10 @@ struct ContentView: View {
})
.alert("content.alert.bluetooth_required.title", isPresented: rootBluetoothAlertBinding) {
Button("content.alert.bluetooth_required.settings") {
SystemSettings.bluetooth.open()
// Powered-off needs the radio controls, not the privacy pane.
(appChromeModel.bluetoothState == .poweredOff
? SystemSettings.bluetoothPower
: SystemSettings.bluetooth).open()
}
Button("common.ok", role: .cancel) {}
} message: {
@ -513,14 +516,47 @@ struct ContentView: View {
}
private var headerView: some View {
ContentHeaderView(
showSidebar: $appChromeModel.showSidebar,
showVerifySheet: $showVerifySheet,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize
)
VStack(spacing: 0) {
ContentHeaderView(
showSidebar: $appChromeModel.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) {}
}
}
private var publicMessageList: some View {

View File

@ -64,7 +64,7 @@ struct ImagePickerView: UIViewControllerRepresentable {
.resizable()
.scaledToFit()
} else {
Text("No image selected")
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
}
Button("Show") { isPresented = true }
}

View File

@ -63,7 +63,7 @@ struct MacImagePickerView: View {
.resizable()
.scaledToFit()
} else {
Text("No image selected")
Text(String(localized: "image_picker.none_selected", defaultValue: "no image selected", comment: "Placeholder shown in the image picker before a selection"))
}
Button("Show") { isPresented = true }
}

View File

@ -27,6 +27,9 @@ struct LocationChannelsSheet: View {
static let grantToFind: LocalizedStringKey = "location_channels.grant_to_find"
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
// Same string the settings pane shows under the tor toggle — the
// warning belongs wherever the exposure is about to happen.
static let torOffWarning = String(localized: "app_info.settings.tor.off_warning", defaultValue: "tor is off: every relay you connect to can see your IP address, including relays carrying your private messages.", comment: "Warning shown under the tor toggle while tor is switched off, stating that relay operators can see the device IP address")
static let quickJoinTitle = String(localized: "location_channels.quick_join.title", defaultValue: "quick join", comment: "Section header in the location channels sheet for the one-tap suggestion of the region channel derived from the device region")
static func quickJoinDescription(_ regionName: String) -> String {
@ -126,6 +129,16 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
// The description's tor claim is only true while tor is on;
// when it's off, say what that exposes right where the person
// is about to join a channel, not just in settings.
if !locationChannelsModel.userTorEnabled {
Text(verbatim: Strings.torOffWarning)
.bitchatFont(size: 11)
.foregroundColor(palette.alertRed)
.fixedSize(horizontal: false, vertical: true)
}
Group {
switch locationChannelsModel.permissionState {
case .notDetermined:

View File

@ -6,6 +6,7 @@
//
import BitFoundation
import CoreBluetooth
import SwiftUI
private struct MessageDisplayItem: Identifiable {
@ -176,7 +177,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)
MeshEmptyStateView(compact: true, bluetoothAvailable: bluetoothCanScan)
.padding(.horizontal, 12)
.padding(.top, 20)
.padding(.bottom, 8)
@ -296,6 +297,15 @@ 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)"
@ -373,7 +383,10 @@ private extension MessageListView {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
MeshEmptyStateView(fillHeight: max(0, fillHeight - 24))
MeshEmptyStateView(
fillHeight: max(0, fillHeight - 24),
bluetoothAvailable: bluetoothCanScan
)
case .location(let channel):
emptyStateLine(
String(

View File

@ -0,0 +1,89 @@
//
// RecentChatList.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import SwiftUI
/// "chats" section for the people sheet: direct conversations with people
/// who are not in any roster above it (offline passersby, geoDMs from a
/// channel since left). Before this section existed, those threads were
/// unreachable the moment the unread envelope cleared — still in memory,
/// no row anywhere in the UI. Renders nothing when there are none.
struct RecentChatList: View {
@ThemedPalette private var palette
let chats: [RecentChatRow]
let onTapChat: (PeerID) -> Void
private enum Strings {
static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation")
}
/// Relative "5 min ago" stamps; the formatter is locale-aware.
private static let relativeFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
var body: some View {
if !chats.isEmpty {
VStack(alignment: .leading, spacing: 0) {
// Same glyph+label header shape as #mesh / groups.
PeopleSectionHeader(
icon: "bubble.left.and.bubble.right",
iconColor: palette.secondary,
title: Strings.header
)
ForEach(chats) { chat in
HStack(spacing: 4) {
Text(verbatim: chat.displayName)
.bitchatFont(size: 14)
.foregroundColor(palette.primary)
.lineLimit(1)
.truncationMode(.tail)
Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()))
.bitchatFont(size: 11)
.foregroundColor(palette.secondary.opacity(0.8))
Spacer()
if chat.hasUnread {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 10))
.foregroundColor(.orange)
.help(Strings.newMessagesTooltip)
}
}
.padding(.horizontal)
.padding(.vertical, 6)
.contentShape(Rectangle())
.onTapGesture { onTapChat(chat.peerID) }
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: chat))
.accessibilityAddTraits(.isButton)
.accessibilityHint(Strings.openChatHint)
}
}
}
}
private func accessibilityDescription(for chat: RecentChatRow) -> String {
var parts: [String] = [
chat.displayName,
Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())
]
if chat.hasUnread { parts.append(Strings.unread) }
return parts.joined(separator: ", ")
}
}

View File

@ -69,7 +69,11 @@ private func makeArchitectureMessage(
@MainActor
private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000,
// 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),
pollNanoseconds: UInt64 = 20_000_000,
_ condition: @escaping @MainActor () -> Bool
) async {
@ -458,6 +462,146 @@ 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 {

View File

@ -36,6 +36,30 @@ struct BitchatPeerTests {
#expect(peer.statusIcon == "🌙")
}
@Test("Mutual favorite without a stored Nostr key is offline, not nostr-available")
func mutualFavoriteWithoutNostrKeyIsOffline() {
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
peer.favoriteStatus = FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nil,
peerNickname: "A",
isFavorite: true,
theyFavoritedUs: true,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
// Nothing to seal a Nostr envelope to: claiming availability here
// would relight the DM header's "end-to-end encrypted" caption with
// neither a Noise session nor a usable recipient key.
#expect(peer.connectionState == .offline)
peer.nostrPublicKey = "abcdef"
#expect(peer.connectionState == .nostrAvailable)
}
@Test("Mutual offline peers show Nostr icon")
func mutualFavoriteOfflinePeerShowsNostrIcon() {
let peerID = PeerID(str: "0011223344556677")

View File

@ -7,12 +7,10 @@
// `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the geohash-screenshot branch publishes via
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
// by the full view-model tests. The GeoDM read pass, the favorites-backed
// mesh/Nostr read-receipt branch (favorites are injected through the
// context), message merging, screenshot notices, and lifecycle persistence
// flows are covered here.
// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr
// read-receipt branch (favorites are injected through the context), message
// merging, DM screenshot notices, and lifecycle persistence flows are
// covered here. Screenshots are never announced to public channels.
//
import Testing
@ -42,7 +40,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var ownerLevelReadPasses: [PeerID] = []
private(set) var managerReadMarks: [PeerID] = []
private(set) var systemMessages: [String] = []
// Conversation store intents
@discardableResult
@ -79,8 +76,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
work()
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Peers & sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var peersByID: [PeerID: BitchatPeer] = [:]
@ -99,7 +94,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
// Routing & receipts
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var meshBroadcasts: [String] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
@ -112,20 +106,12 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
return routeReadReceiptResult
}
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
meshBroadcasts.append(content)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Nostr & geohash
var isTeleported = false
private(set) var recordedGeoParticipants: [String] = []
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
@ -264,40 +250,39 @@ struct ChatLifecycleCoordinatorContextTests {
}
@Test @MainActor
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.nicknamesByPeerID[peerID] = "alice"
// No established session: local notice only, no network send.
// No established session: nothing goes out, so nothing is echoed —
// a local "you took a screenshot" would imply the peer was told.
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
#expect(context.privateChats[peerID]?.first?.sender == "system")
#expect(context.privateChats.isEmpty)
// Established session: the peer is notified too.
// Established session: the peer is notified and the echo appears.
context.noiseSessionStates[peerID] = .established
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.count == 1)
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
#expect(context.privateChats[peerID]?.count == 2)
// The public-channel system message is not used for private chats.
#expect(context.systemMessages.isEmpty)
#expect(context.meshBroadcasts.isEmpty)
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
#expect(context.privateChats[peerID]?.first?.sender == "system")
}
@Test @MainActor
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
func handleScreenshotCaptured_publicChannel_staysSilent() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
// No DM selected: screenshots must never announce presence to a
// public channel (mesh broadcast or Nostr relays).
coordinator.handleScreenshotCaptured()
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
#expect(context.systemMessages == ["you took a screenshot"])
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.privateChats.isEmpty)
}
@ -365,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests {
}
}
// MARK: - Screenshot Routing
/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path,
/// the App Info exemption, and the deliberate mesh silence. Combined with
/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves
/// location + screenshot → local privacy alert, and nothing sent anywhere.
struct ScreenshotCaptureRoutingTests {
@Test("Location channels warn locally; the sheet warns everywhere")
func locationScreenshotsWarnLocally() {
// Geohash timeline, no DM open: warn the person, tell no one.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .warnLocally)
// The channel sheet reveals location regardless of active channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: true,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: false
) == .warnLocally)
}
@Test("App Info is exempt; mesh and DMs forward to the chat layer")
func nonLocationScreenshotsForwardOrIgnore() {
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: true,
hasPrivateChatOpen: false,
isLocationChannelActive: true
) == .ignore)
// Mesh timeline: forwarded, where the coordinator stays silent for
// public channels — no local alert either (nothing is sent and no
// place is revealed; see ScreenshotCaptureResponse docs).
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: false,
isLocationChannelActive: false
) == .forwardToChat)
// An open DM keeps its peer notice even from a location channel.
#expect(AppRuntime.resolveScreenshotResponse(
isLocationChannelsSheetPresented: false,
isAppInfoPresented: false,
hasPrivateChatOpen: true,
isLocationChannelActive: true
) == .forwardToChat)
}
}

View File

@ -653,8 +653,7 @@ struct ChatMediaTransferCoordinatorContextTests {
@Test @MainActor
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
async throws
{
async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID

View File

@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests {
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(delivered)
}

View File

@ -402,6 +402,37 @@ struct ChatPeerIdentityCoordinatorContextTests {
#expect(context.cachedEncryptionStatuses[peerID] == nil)
}
@Test @MainActor
func getEncryptionStatus_reflectsLiveSessionNotHistory() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "8877665544332211")
// A remembered (even verified) fingerprint must not conjure a lock on
// its own: the DM header and "end-to-end encrypted" caption follow
// this status, and claiming secured with no live session is the false
// security signal this mapping used to produce.
context.fingerprintsByPeerID[peerID] = "fp"
context.verifiedFingerprintSet = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
coordinator.invalidateEncryptionCache(for: peerID)
context.noiseSessionStates[peerID] = .handshaking
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseHandshaking)
// A FAILED handshake is a red lock.slash, not yesterday's seal.
struct HandshakeError: Error {}
coordinator.invalidateEncryptionCache(for: peerID)
context.noiseSessionStates[peerID] = .failed(HandshakeError())
#expect(coordinator.getEncryptionStatus(for: peerID) == .none)
// Only a live established session upgrades to the verified seal.
coordinator.invalidateEncryptionCache(for: peerID)
context.noiseSessionStates[peerID] = .established
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
}
@Test @MainActor
func resolveNickname_walksMeshIdentityAndAnonFallbacks() async {
let context = MockChatPeerIdentityContext()

View File

@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests {
LocationChannelManager.shared.select(channel)
defer { LocationChannelManager.shared.select(.mesh) }
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout)
let (viewModel, _) = makeTestableViewModel()
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout)
let signer = try NostrIdentity.generate()
let event = NostrEvent(
@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didAppend)
}
@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests {
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didStore)
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests {
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didAck)
#expect(viewModel.privateChats[convKey] == nil)
@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests {
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 5.0
timeout: TestConstants.settleTimeout
)
#expect(didUpdate)
}
@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests {
viewModel.sendVoiceNote(at: url)
// Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
// shared with every parallel test worker, so a loaded runner can be
// starved for seconds. waitUntil returns as soon as the condition
// holds, so passing runs never pay the settle deadline.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
let showedSecond = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
#expect(showedSecond)
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests {
)
let advanced = await TestHelpers.waitUntil(
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
timeout: TestConstants.longTimeout
timeout: TestConstants.settleTimeout
)
#expect(advanced)
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: TestConstants.longTimeout)
}, timeout: TestConstants.settleTimeout)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)

View File

@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests {
}
@Test @MainActor
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
@ -437,8 +437,10 @@ struct ChatViewModelServiceLifecycleTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleScreenshotCaptured()
// No session means no notice went out, so no local echo either —
// an echo here would imply Alice was told when she wasn't.
#expect(transport.sentPrivateMessages.isEmpty)
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
#expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true)
}
}
@ -2261,7 +2263,9 @@ struct ChatViewModelPanicTests {
// After panic, emergency disconnect should be called
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(viewModel.messages.isEmpty)
// 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.privateChats.isEmpty)
#expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil)

View File

@ -165,14 +165,33 @@ 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
// 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.
#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)
}
}

View File

@ -255,7 +255,9 @@ struct CommandProcessorTests {
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
// "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")
default:
Issue.record("Expected success result")
}

View File

@ -0,0 +1,31 @@
//
// 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)
}
}

View File

@ -115,13 +115,18 @@ struct PrivateChatE2ETests {
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
// Manager sessions are keyed by key-derived wire IDs: handshake
// completion fails closed on IDs the remote key can't vouch for.
let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
// Establish encrypted session
do {
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID)
let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)!
let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)!
_ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3)
} catch {
Issue.record("Failed to establish Noise session: \(error)")
}
@ -134,7 +139,7 @@ struct PrivateChatE2ETests {
let message = BitchatMessage(packet.payload),
message.isPrivate {
do {
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID)
let encryptedPacket = BitchatPacket(
type: 0x02, // Encrypted message type
senderID: packet.senderID,
@ -155,7 +160,7 @@ struct PrivateChatE2ETests {
// Decrypt incoming encrypted messages
if packet.type == 0x02 {
do {
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID)
if let message = BitchatMessage(decrypted) {
#expect(message.content == TestConstants.testMessage1)
#expect(message.isPrivate)

View File

@ -18,10 +18,10 @@ struct IntegrationTests {
private var helper = TestNetworkHelper()
init() {
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
helper.createNode("Alice")
helper.createNode("Bob")
helper.createNode("Charlie")
helper.createNode("David")
}
// MARK: - Multi-Peer Scenarios
@ -266,9 +266,11 @@ struct IntegrationTests {
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
}
// Simulate Bob restart by recreating his Noise manager
// Simulate Bob restart by recreating his Noise manager. A new static
// key means a new key-derived wire ID, just like production.
let bobKey = Curve25519.KeyAgreement.PrivateKey()
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
// Re-establish Noise handshake explicitly via managers
do {
@ -320,7 +322,7 @@ struct IntegrationTests {
@Test func largeScaleNetwork() async throws {
// Create larger network
for i in 5...10 {
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
helper.createNode("Node\(i)")
}
// Connect in ring topology with cross-connections

View File

@ -37,7 +37,7 @@ struct LargeTopologyTests {
private func makeNodes(_ names: [String]) {
for name in names {
helper.createNode(name, peerID: PeerID(str: UUID().uuidString))
helper.createNode(name)
}
}

View File

@ -22,15 +22,17 @@ final class TestNetworkHelper {
// MARK: - Node/Manager management
@discardableResult
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
func createNode(_ name: String) -> MockBLEService {
let node = MockBLEService(bus: bus)
node.myPeerID = peerID
// Wire IDs must derive from the node's Noise static key: handshake
// completion fails closed on IDs the remote key can't vouch for.
let key = Curve25519.KeyAgreement.PrivateKey()
node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation)
node.mockNickname = name
nodes[name] = node
// This synchronous helper directly drives all three XX messages and
// has no transport callback loop for delayed collision recovery.
let key = Curve25519.KeyAgreement.PrivateKey()
noiseManagers[name] = NoiseSessionManager(
localStaticKey: key,
keychain: mockKeychain,

View File

@ -69,4 +69,45 @@ 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: ", "))"
)
}
}

View File

@ -578,6 +578,26 @@ struct NoiseCoverageTests {
#expect(failingManager.getSession(for: charliePeerID) == nil)
}
@Test("Handshake completion fails closed on non-wire peer IDs")
func handshakeCompletionRejectsNonWirePeerIDs() throws {
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
// Alice addresses Bob by an identifier no static key can vouch for:
// neither a 16-hex wire ID nor a full Noise-key ID. Completion must
// reject it rather than accept any remote static key.
let nonWireID = PeerID(str: "not-a-wire-identifier")
let msg1 = try aliceManager.initiateHandshake(with: nonWireID)
let msg2 = try #require(
try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)
)
#expect(throws: (any Error).self) {
try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2)
}
#expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true)
}
@Test("Session manager cleans up initiator sessions after start-handshake failures")
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
let manager = NoiseSessionManager(

View File

@ -68,22 +68,29 @@ struct NoiseProtocolTests {
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
private let mockKeychain = MockKeychain()
private let alicePeerID = PeerID(str: UUID().uuidString)
private let bobPeerID = PeerID(str: UUID().uuidString)
// Manager sessions are keyed by the remote peer. Keep the historical
// names, but derive each wire ID from the static key that the
// corresponding manager authenticates during the handshake.
private var alicePeerID: PeerID {
PeerID(publicKey: bobKey.publicKey.rawRepresentation)
}
private var bobPeerID: PeerID {
PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
}
private let aliceSession: NoiseSession
private let bobSession: NoiseSession
init() {
aliceSession = NoiseSession(
peerID: alicePeerID,
peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation),
role: .initiator,
keychain: mockKeychain,
localStaticKey: aliceKey
)
bobSession = NoiseSession(
peerID: bobPeerID,
peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation),
role: .responder,
keychain: mockKeychain,
localStaticKey: bobKey

View File

@ -0,0 +1,32 @@
//
// NostrInboundPipelineTimestampTests.swift
// bitchatTests
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Testing
@testable import bitchat
struct NostrInboundPipelineTimestampTests {
private let now = Date(timeIntervalSince1970: 1_700_000_000)
private let nowSeconds = 1_700_000_000
private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds)
private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds)
@Test("Rumor timestamps inside the lookback-plus-skew window are accepted")
func acceptsPlausibleTimestamps() {
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now))
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now))
// A sender clock slightly ahead of the receiver is tolerated.
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now))
}
@Test("Future-dated and stale rumor timestamps are rejected")
func rejectsImplausibleTimestamps() {
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now))
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now))
}
}

View File

@ -150,14 +150,12 @@ struct BLENoisePacketHandlerTests {
recorder.clearedSessions.append(peerID)
service.clearSession(for: peerID)
},
handleAuthenticatedPeerState: {
peerID, payload, generation in
handleAuthenticatedPeerState: { peerID, payload, generation in
recorder.authenticatedPeerStates.append(
(peerID, payload, generation)
)
},
deliverNoisePayload: {
peerID, type, payload, timestamp in
deliverNoisePayload: { peerID, type, payload, timestamp in
recorder.deliveries.append(
(peerID, type, payload, timestamp)
)

View File

@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase {
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
}
func test_verifyScannedQR_rejectsFutureDatedPayload() throws {
let (service, noise) = makeService()
let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970)
let qrString = try makeSignedQR(
noise: noise,
nickname: "future-\(UUID().uuidString)",
npub: nil,
ts: futureTimestamp
)
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
}
func test_verifyScannedQR_rejectsTamperedSignature() throws {
let (service, noise) = makeService()
let badSignature = Data(repeating: 0xAA, count: 64)

View File

@ -56,7 +56,21 @@ struct SimulatedMeshTests {
mesh.connect(1, 2)
mesh.announceAll()
mesh.advanceTime(by: 2)
// Discovery is not quiet after one advance: every first-seen peer
// schedules an afterglow re-announce at a RANDOM 0.3–0.6s delay
// (BLEAnnounceHandler), and each of those can cascade another relay
// round. Whether that traffic lands before or after a one-shot
// baseline snapshot depends on the draw — the budget assertion below
// flaked on CI at 14 and 18 frames for exactly that reason. Advance
// until the mesh goes a full window with no new frames, so the
// baseline only ever measures the message under test.
var settled = mesh.deliveredFrameCount
for _ in 0..<20 {
mesh.advanceTime(by: 2)
let now = mesh.deliveredFrameCount
if now == settled { break }
settled = now
}
let baseline = mesh.deliveredFrameCount
let capture = TransportEventCapture()

View File

@ -8,6 +8,9 @@
import Foundation
// periphery:ignore - intentionally unreferenced by production code; nothing
// emits or consumes this type yet, and BLEService parses it only to ignore it.
// Delete this annotation when the mesh starts using it.
/// Identity-free presence announcement for rotating peer IDs.
///
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the
@ -37,9 +40,6 @@ import Foundation
/// `docs/PEER-ID-ROTATION.md`.
///
/// Not emitted or consumed by the shipping mesh yet.
// periphery:ignore - intentionally unreferenced by production code; nothing
// emits or consumes this type yet, and BLEService parses it only to ignore it.
// Delete this annotation when the mesh starts using it.
public struct AnnounceV2Packet: Equatable, Sendable {
/// Rotation epoch this announce was built for. Carried explicitly so a
/// receiver matches against a stated epoch instead of guessing.

View File

@ -9,6 +9,10 @@
import Foundation
private import CryptoKit
// periphery:ignore - intentionally unreferenced by production code. These are
// the reviewable primitives for a protocol change that cannot ship until both
// platforms agree on it; wiring them into the transport is the next step, not
// this one. Delete this annotation when the mesh starts using them.
/// Derivations for rotating peer IDs and pairwise recognition tags.
///
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
@ -32,10 +36,6 @@ private import CryptoKit
/// Recognition tags come from the X25519 shared secret between two static
/// keys, so exactly two parties can compute a given tag and an observer can
/// compute none of them.
// periphery:ignore - intentionally unreferenced by production code. These are
// the reviewable primitives for a protocol change that cannot ship until both
// platforms agree on it; wiring them into the transport is the next step, not
// this one. Delete this annotation when the mesh starts using them.
public enum PeerIDRotation {
// MARK: - Parameters