mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-10-25 07:41:41 +00:00
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:
commit
3c6fafb860
8
.github/workflows/periphery.yml
vendored
8
.github/workflows/periphery.yml
vendored
@ -11,9 +11,11 @@ jobs:
|
||||
name: Periphery scan
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
||||
# block merges. Drop continue-on-error once the baseline proves stable.
|
||||
continue-on-error: true
|
||||
# Blocking, like SwiftLint since #1646. The baseline (committed July 8)
|
||||
# has been stable across a month of merges — the "drop continue-on-error
|
||||
# once the baseline proves stable" condition this job shipped with is met.
|
||||
# Known macOS-scan false positives stay suppressed by the baseline; only
|
||||
# NEW dead code fails the job (--strict below).
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
13
.github/workflows/swift-tests.yml
vendored
13
.github/workflows/swift-tests.yml
vendored
@ -264,11 +264,13 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
# Blocking since the backlog reached zero (all violations fixed; rules the
|
||||
# codebase deliberately breaks are disabled in .swiftlint.yml). --strict
|
||||
# promotes warnings to errors so new violations fail the job. Runs in a
|
||||
# pinned container (no Xcode plugin, no pbxproj changes) so it can never
|
||||
# break the documented xcodebuild path.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
name: SwiftLint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
@ -280,10 +282,9 @@ jobs:
|
||||
# Tag for readability, digest for immutability (tags can be repointed).
|
||||
# Bump both together, deliberately — never a floating tag.
|
||||
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
run: swiftlint lint --strict --reporter github-actions-logging
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
excluded:
|
||||
- .build
|
||||
- .claude
|
||||
- .device-lab
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
@ -21,6 +22,7 @@ disabled_rules:
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
- todo # TODOs that cite a tracked issue are deliberate markers, not lint debt
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,12 +39,27 @@ struct GroupChatRow: Identifiable, Equatable {
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
/// A direct conversation whose person is NOT in any roster above it. Without
|
||||
/// this section, a DM from a passerby became unreachable the moment they went
|
||||
/// offline: the roster filters to connected/reachable/mutual-favorite, the
|
||||
/// header envelope only exists while unread, and /msg can't resolve offline
|
||||
/// strangers — the thread was still in memory with no row anywhere in the UI.
|
||||
struct RecentChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let displayName: String
|
||||
let hasUnread: Bool
|
||||
let lastActivity: Date
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@Published private(set) var recentChatRows: [RecentChatRow] = []
|
||||
@Published private(set) var reachableMeshPeerCount = 0
|
||||
@Published private(set) var connectedMeshPeerCount = 0
|
||||
@Published private(set) var visibleGeohashPeerCount = 0
|
||||
@ -143,6 +158,17 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Direct-conversation changes reorder or create recent-chat rows.
|
||||
// Filtered to `.direct` so public-timeline traffic (every mesh or
|
||||
// geohash message emits a change) doesn't rebuild the sheet.
|
||||
conversations.changes
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] change in
|
||||
guard case .direct = Self.changedConversationID(change) else { return }
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@ -239,6 +265,7 @@ final class PeerListModel: ObservableObject {
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
@ -246,6 +273,7 @@ final class PeerListModel: ObservableObject {
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
self.recentChatRows = recentChatRows
|
||||
renderID = (
|
||||
meshRows.map {
|
||||
"\($0.id)-\($0.displayName)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||
@ -255,10 +283,101 @@ final class PeerListModel: ObservableObject {
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
} +
|
||||
recentChatRows.map {
|
||||
"chat:\($0.id)-\($0.displayName)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
/// Direct conversations with people absent from the rosters above:
|
||||
/// the offline passerby DM, the geoDM from a channel since left. Rows
|
||||
/// mirrored under both an ephemeral and a stable peer ID collapse to
|
||||
/// one row per identity (newest activity wins), matching how the
|
||||
/// private-chat coordinators consolidate on open.
|
||||
private func buildRecentChatRows(
|
||||
meshRows: [MeshPeerRow],
|
||||
geohashPeople: [GeohashPersonRow]
|
||||
) -> [RecentChatRow] {
|
||||
// A conversation can be keyed by the stable Noise peer ID while the
|
||||
// roster lists the ephemeral one — compare fingerprints too.
|
||||
var visibleIdentities = Set<String>()
|
||||
for row in meshRows {
|
||||
visibleIdentities.insert(row.peerID.id)
|
||||
if let fingerprint = chatViewModel.getFingerprint(for: row.peerID) {
|
||||
visibleIdentities.insert(fingerprint)
|
||||
}
|
||||
}
|
||||
// Someone visible in the geohash section must not also get a chat
|
||||
// row: their GeoDM conversation is keyed by the nostr_ form of the
|
||||
// same pubkey the roster lists.
|
||||
for person in geohashPeople {
|
||||
visibleIdentities.insert(PeerID(nostr_: person.id).id)
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
let peerID: PeerID
|
||||
let lastActivity: Date
|
||||
}
|
||||
var bestByIdentity: [String: Candidate] = [:]
|
||||
|
||||
for (id, conversation) in conversations.conversationsByID {
|
||||
guard case .direct(let handle) = id else { continue }
|
||||
let peerID = handle.routingPeerID
|
||||
// Groups have their own section; blocked people get no row.
|
||||
guard !peerID.isGroup else { continue }
|
||||
guard let lastMessage = conversation.messages.last else { continue }
|
||||
guard !chatViewModel.isPeerBlocked(peerID) else { continue }
|
||||
|
||||
let fingerprint = chatViewModel.getFingerprint(for: peerID)
|
||||
if visibleIdentities.contains(peerID.id) { continue }
|
||||
if let fingerprint, visibleIdentities.contains(fingerprint) { continue }
|
||||
|
||||
let identityKey = fingerprint ?? peerID.id
|
||||
if let existing = bestByIdentity[identityKey],
|
||||
existing.lastActivity >= lastMessage.timestamp {
|
||||
continue
|
||||
}
|
||||
bestByIdentity[identityKey] = Candidate(peerID: peerID, lastActivity: lastMessage.timestamp)
|
||||
}
|
||||
|
||||
return bestByIdentity.values
|
||||
.sorted { $0.lastActivity > $1.lastActivity }
|
||||
.map { candidate in
|
||||
RecentChatRow(
|
||||
peerID: candidate.peerID,
|
||||
displayName: displayName(for: candidate.peerID),
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: candidate.peerID),
|
||||
lastActivity: candidate.lastActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// GeoDM conversations resolve through the Nostr mapping —
|
||||
/// `resolveNickname` only consults mesh identity sources and would
|
||||
/// render a `nostr_…` key as an opaque `anonnost` fallback.
|
||||
private func displayName(for peerID: PeerID) -> String {
|
||||
if peerID.isGeoDM {
|
||||
return chatViewModel.geohashDisplayName(for: peerID)
|
||||
}
|
||||
return chatViewModel.resolveNickname(for: peerID)
|
||||
}
|
||||
|
||||
private static func changedConversationID(_ change: ConversationChange) -> ConversationID {
|
||||
switch change {
|
||||
case .appended(let id, _),
|
||||
.updated(let id, _),
|
||||
.statusChanged(let id, _, _),
|
||||
.messageRemoved(let id, _),
|
||||
.cleared(let id),
|
||||
.removed(let id),
|
||||
.unreadChanged(let id, _):
|
||||
return id
|
||||
case .migrated(_, let to):
|
||||
return to
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
|
||||
@ -357,7 +357,10 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
favoriteStatus.isMutual {
|
||||
favoriteStatus.isMutual,
|
||||
// No stored recipient key means no Nostr delivery — and the
|
||||
// "end-to-end encrypted" caption keys off .nostrAvailable.
|
||||
favoriteStatus.peerNostrPublicKey != nil {
|
||||
return .nostrAvailable
|
||||
}
|
||||
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,13 +33,22 @@ struct BitchatPeer: Equatable {
|
||||
return .bluetoothConnected
|
||||
} else if isReachable {
|
||||
return .meshReachable
|
||||
} else if favoriteStatus?.isMutual == true {
|
||||
// Mutual favorites can communicate via Nostr when offline
|
||||
} else if favoriteStatus?.isMutual == true, reachableNostrPublicKey != nil {
|
||||
// Mutual favorites can communicate via Nostr when offline — but
|
||||
// only with a stored recipient key. NostrTransport applies the
|
||||
// same rule when computing reachable peers; without a key,
|
||||
// "available" would be a lie, and the DM header's
|
||||
// "end-to-end encrypted" caption keys off this state.
|
||||
return .nostrAvailable
|
||||
} else {
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
/// The Nostr key a private envelope would actually be sealed to, if any.
|
||||
var reachableNostrPublicKey: String? {
|
||||
nostrPublicKey ?? favoriteStatus?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
var isFavorite: Bool {
|
||||
favoriteStatus?.isFavorite ?? false
|
||||
|
||||
@ -1001,8 +1001,7 @@ final class NoiseSessionManager {
|
||||
0,
|
||||
recentInitiatorCompletionGracePeriod - elapsed
|
||||
)
|
||||
let timeout = DispatchWorkItem(flags: .barrier) {
|
||||
[weak self, weak establishedSession] in
|
||||
let timeout = DispatchWorkItem(flags: .barrier) { [weak self, weak establishedSession] in
|
||||
guard let self,
|
||||
let establishedSession,
|
||||
let current = self.sessions[peerID],
|
||||
@ -1126,8 +1125,8 @@ final class NoiseSessionManager {
|
||||
|
||||
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||
/// also accepted by internal callers when they exactly match the static
|
||||
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||
/// key. Anything else fails closed: an identifier that can't be checked
|
||||
/// against the remote static key must never complete a handshake.
|
||||
private func authenticatedRemoteKey(
|
||||
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||
matches claimedPeerID: PeerID
|
||||
@ -1139,7 +1138,7 @@ final class NoiseSessionManager {
|
||||
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||
return claimedNoiseKey == rawKey
|
||||
}
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@ -76,10 +76,9 @@ final class NostrIdentityBridge {
|
||||
deviceSeedCache = existing
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
// CryptoKit key generation cannot fail, unlike SecRandomCopyBytes —
|
||||
// a discarded failure here would persist an all-zero identity seed.
|
||||
let seed = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
|
||||
// Ensure availability after first unlock to prevent unintended rotation when locked
|
||||
keychain.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
|
||||
deviceSeedCache = seed
|
||||
|
||||
@ -836,9 +836,12 @@ struct NostrEvent: Codable {
|
||||
// Sign with Schnorr (BIP-340)
|
||||
var messageBytes = [UInt8](eventIdHash)
|
||||
var auxRand = [UInt8](repeating: 0, count: 32)
|
||||
_ = auxRand.withUnsafeMutableBytes { ptr in
|
||||
let auxStatus = auxRand.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
guard auxStatus == errSecSuccess else {
|
||||
throw NostrError.cryptographicFailure
|
||||
}
|
||||
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
|
||||
|
||||
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
|
||||
|
||||
@ -4027,10 +4027,8 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
}
|
||||
service.onRekeyHandshakeReady = {
|
||||
[weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
service.onRekeyHandshakeReady = { [weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
@ -4046,14 +4044,12 @@ extension BLEService {
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onHandshakeRecoveryRequired = {
|
||||
[weak self, weak service] request in
|
||||
service.onHandshakeRecoveryRequired = { [weak self, weak service] request in
|
||||
guard let self, let service else { return }
|
||||
#if DEBUG
|
||||
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
|
||||
#endif
|
||||
self.messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
self.messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
@ -5294,8 +5290,7 @@ extension BLEService {
|
||||
) else {
|
||||
return
|
||||
}
|
||||
messageQueue.async {
|
||||
[weak self, weak service] in
|
||||
messageQueue.async { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -216,6 +216,10 @@ enum TransportConfig {
|
||||
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
|
||||
static let nostrGeohashSampleLimit: Int = 100
|
||||
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
|
||||
// Tolerated clock skew for the client-side rumor-timestamp window on
|
||||
// inbound Nostr DMs (senders stamp the inner rumor with real time; only
|
||||
// the outer gift wrap is randomized per NIP-17).
|
||||
static let nostrDMMaxClockSkewSeconds: TimeInterval = 900
|
||||
// A sampled chat message this recent means "a conversation is happening
|
||||
// there" for the empty-timeline nearby-activity hint.
|
||||
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
|
||||
|
||||
@ -84,7 +84,8 @@ final class VerificationService {
|
||||
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let status = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
guard status == errSecSuccess else { return nil }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
@ -105,9 +106,10 @@ final class VerificationService {
|
||||
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
|
||||
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
|
||||
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
|
||||
// Freshness
|
||||
// Freshness, in both directions: a future-dated timestamp must not
|
||||
// buy a QR a longer validity window than a fresh one gets.
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
if abs(now - Double(qr.ts)) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let transport = transport else { return nil }
|
||||
|
||||
@ -108,6 +108,6 @@ struct QuickJoinSuggestion {
|
||||
"UY": "6c", "UZ": "tx", "VA": "sr", "VC": "dd",
|
||||
"VE": "d9", "VI": "de", "VN": "w7", "VU": "rs",
|
||||
"WS": "2j", "XK": "sr", "YE": "sf", "YT": "mj",
|
||||
"ZA": "ke", "ZM": "kt", "ZW": "ks",
|
||||
"ZA": "ke", "ZM": "kt", "ZW": "ks"
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
116
bitchat/Views/Components/ConnectivityStatusBanner.swift
Normal file
116
bitchat/Views/Components/ConnectivityStatusBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
42
bitchat/Views/Components/PanicWipeBlockedBanner.swift
Normal file
42
bitchat/Views/Components/PanicWipeBlockedBanner.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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.)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
89
bitchat/Views/RecentChatList.swift
Normal file
89
bitchat/Views/RecentChatList.swift
Normal file
@ -0,0 +1,89 @@
|
||||
//
|
||||
// RecentChatList.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import SwiftUI
|
||||
|
||||
/// "chats" section for the people sheet: direct conversations with people
|
||||
/// who are not in any roster above it (offline passersby, geoDMs from a
|
||||
/// channel since left). Before this section existed, those threads were
|
||||
/// unreachable the moment the unread envelope cleared — still in memory,
|
||||
/// no row anywhere in the UI. Renders nothing when there are none.
|
||||
struct RecentChatList: View {
|
||||
@ThemedPalette private var palette
|
||||
|
||||
let chats: [RecentChatRow]
|
||||
let onTapChat: (PeerID) -> Void
|
||||
|
||||
private enum Strings {
|
||||
static let header = String(localized: "chats.section.header", defaultValue: "chats", comment: "Section header above recent direct conversations in the people sheet")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let openChatHint = String(localized: "chats.accessibility.open_hint", defaultValue: "opens this conversation", comment: "Accessibility hint on a recent chat row explaining activation opens the direct conversation")
|
||||
}
|
||||
|
||||
/// Relative "5 min ago" stamps; the formatter is locale-aware.
|
||||
private static let relativeFormatter: RelativeDateTimeFormatter = {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
if !chats.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Same glyph+label header shape as #mesh / groups.
|
||||
PeopleSectionHeader(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
iconColor: palette.secondary,
|
||||
title: Strings.header
|
||||
)
|
||||
|
||||
ForEach(chats) { chat in
|
||||
HStack(spacing: 4) {
|
||||
Text(verbatim: chat.displayName)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
|
||||
Text(verbatim: Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date()))
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
|
||||
Spacer()
|
||||
|
||||
if chat.hasUnread {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.orange)
|
||||
.help(Strings.newMessagesTooltip)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTapChat(chat.peerID) }
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: chat))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(Strings.openChatHint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func accessibilityDescription(for chat: RecentChatRow) -> String {
|
||||
var parts: [String] = [
|
||||
chat.displayName,
|
||||
Self.relativeFormatter.localizedString(for: chat.lastActivity, relativeTo: Date())
|
||||
]
|
||||
if chat.hasUnread { parts.append(Strings.unread) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -36,6 +36,30 @@ struct BitchatPeerTests {
|
||||
#expect(peer.statusIcon == "🌙")
|
||||
}
|
||||
|
||||
@Test("Mutual favorite without a stored Nostr key is offline, not nostr-available")
|
||||
func mutualFavoriteWithoutNostrKeyIsOffline() {
|
||||
let peerID = PeerID(str: "0123456789abcdef")
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
|
||||
peer.favoriteStatus = FavoriteRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: nil,
|
||||
peerNickname: "A",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true,
|
||||
favoritedAt: Date(timeIntervalSince1970: 1),
|
||||
lastUpdated: Date(timeIntervalSince1970: 2)
|
||||
)
|
||||
|
||||
// Nothing to seal a Nostr envelope to: claiming availability here
|
||||
// would relight the DM header's "end-to-end encrypted" caption with
|
||||
// neither a Noise session nor a usable recipient key.
|
||||
#expect(peer.connectionState == .offline)
|
||||
|
||||
peer.nostrPublicKey = "abcdef"
|
||||
#expect(peer.connectionState == .nostrAvailable)
|
||||
}
|
||||
|
||||
@Test("Mutual offline peers show Nostr icon")
|
||||
func mutualFavoriteOfflinePeerShowsNostrIcon() {
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
|
||||
@ -7,12 +7,10 @@
|
||||
// `ChatDeliveryCoordinatorContextTests` /
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Scope note: the geohash-screenshot branch publishes via
|
||||
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
|
||||
// by the full view-model tests. The GeoDM read pass, the favorites-backed
|
||||
// mesh/Nostr read-receipt branch (favorites are injected through the
|
||||
// context), message merging, screenshot notices, and lifecycle persistence
|
||||
// flows are covered here.
|
||||
// Scope note: the GeoDM read pass, the favorites-backed mesh/Nostr
|
||||
// read-receipt branch (favorites are injected through the context), message
|
||||
// merging, DM screenshot notices, and lifecycle persistence flows are
|
||||
// covered here. Screenshots are never announced to public channels.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@ -42,7 +40,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
var nostrKeyMapping: [PeerID: String] = [:]
|
||||
private(set) var ownerLevelReadPasses: [PeerID] = []
|
||||
private(set) var managerReadMarks: [PeerID] = []
|
||||
private(set) var systemMessages: [String] = []
|
||||
|
||||
// Conversation store intents
|
||||
@discardableResult
|
||||
@ -79,8 +76,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
work()
|
||||
}
|
||||
|
||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||
|
||||
// Peers & sessions
|
||||
var nicknamesByPeerID: [PeerID: String] = [:]
|
||||
var peersByID: [PeerID: BitchatPeer] = [:]
|
||||
@ -99,7 +94,6 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
// Routing & receipts
|
||||
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
|
||||
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
|
||||
private(set) var meshBroadcasts: [String] = []
|
||||
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
|
||||
|
||||
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
@ -112,20 +106,12 @@ private final class MockChatLifecycleContext: ChatLifecycleContext {
|
||||
return routeReadReceiptResult
|
||||
}
|
||||
|
||||
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
||||
meshBroadcasts.append(content)
|
||||
}
|
||||
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
geoReadReceipts.append((messageID, recipientHex))
|
||||
}
|
||||
|
||||
// Nostr & geohash
|
||||
var isTeleported = false
|
||||
private(set) var recordedGeoParticipants: [String] = []
|
||||
|
||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
|
||||
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
|
||||
|
||||
// Favorites
|
||||
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
|
||||
@ -264,40 +250,39 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
|
||||
func handleScreenshotCaptured_privateChat_echoesOnlyWhenPeerWasNotified() async {
|
||||
let context = MockChatLifecycleContext()
|
||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
context.nicknamesByPeerID[peerID] = "alice"
|
||||
|
||||
// No established session: local notice only, no network send.
|
||||
// No established session: nothing goes out, so nothing is echoed —
|
||||
// a local "you took a screenshot" would imply the peer was told.
|
||||
coordinator.handleScreenshotCaptured()
|
||||
#expect(context.routedPrivateMessages.isEmpty)
|
||||
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
#expect(context.privateChats.isEmpty)
|
||||
|
||||
// Established session: the peer is notified too.
|
||||
// Established session: the peer is notified and the echo appears.
|
||||
context.noiseSessionStates[peerID] = .established
|
||||
coordinator.handleScreenshotCaptured()
|
||||
#expect(context.routedPrivateMessages.count == 1)
|
||||
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
|
||||
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
|
||||
#expect(context.privateChats[peerID]?.count == 2)
|
||||
// The public-channel system message is not used for private chats.
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
#expect(context.meshBroadcasts.isEmpty)
|
||||
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
|
||||
#expect(context.privateChats[peerID]?.first?.sender == "system")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
|
||||
func handleScreenshotCaptured_publicChannel_staysSilent() async {
|
||||
let context = MockChatLifecycleContext()
|
||||
let coordinator = ChatLifecycleCoordinator(context: context)
|
||||
|
||||
// No DM selected: screenshots must never announce presence to a
|
||||
// public channel (mesh broadcast or Nostr relays).
|
||||
coordinator.handleScreenshotCaptured()
|
||||
|
||||
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
|
||||
#expect(context.systemMessages == ["you took a screenshot"])
|
||||
#expect(context.routedPrivateMessages.isEmpty)
|
||||
#expect(context.privateChats.isEmpty)
|
||||
}
|
||||
|
||||
@ -365,3 +350,59 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Screenshot Routing
|
||||
|
||||
/// Pins `AppRuntime`'s screenshot decision table: the geohash warn path,
|
||||
/// the App Info exemption, and the deliberate mesh silence. Combined with
|
||||
/// `handleScreenshotCaptured_publicChannel_staysSilent` above, this proves
|
||||
/// location + screenshot → local privacy alert, and nothing sent anywhere.
|
||||
struct ScreenshotCaptureRoutingTests {
|
||||
|
||||
@Test("Location channels warn locally; the sheet warns everywhere")
|
||||
func locationScreenshotsWarnLocally() {
|
||||
// Geohash timeline, no DM open: warn the person, tell no one.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: true
|
||||
) == .warnLocally)
|
||||
|
||||
// The channel sheet reveals location regardless of active channel.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: true,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: true,
|
||||
isLocationChannelActive: false
|
||||
) == .warnLocally)
|
||||
}
|
||||
|
||||
@Test("App Info is exempt; mesh and DMs forward to the chat layer")
|
||||
func nonLocationScreenshotsForwardOrIgnore() {
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: true,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: true
|
||||
) == .ignore)
|
||||
|
||||
// Mesh timeline: forwarded, where the coordinator stays silent for
|
||||
// public channels — no local alert either (nothing is sent and no
|
||||
// place is revealed; see ScreenshotCaptureResponse docs).
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: false,
|
||||
isLocationChannelActive: false
|
||||
) == .forwardToChat)
|
||||
|
||||
// An open DM keeps its peer notice even from a location channel.
|
||||
#expect(AppRuntime.resolveScreenshotResponse(
|
||||
isLocationChannelsSheetPresented: false,
|
||||
isAppInfoPresented: false,
|
||||
hasPrivateChatOpen: true,
|
||||
isLocationChannelActive: true
|
||||
) == .forwardToChat)
|
||||
}
|
||||
}
|
||||
|
||||
@ -653,8 +653,7 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
|
||||
async throws
|
||||
{
|
||||
async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
|
||||
@ -356,7 +356,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(routed)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
@ -412,7 +412,7 @@ struct ChatNostrCoordinatorContextTests {
|
||||
// The pipeline itself stays usable: a gift wrap spawned AFTER the
|
||||
// wipe (new generation) still decrypts and delivers.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(delivered)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -277,11 +277,11 @@ struct ChatViewModelNostrExtensionTests {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
defer { LocationChannelManager.shared.select(.mesh) }
|
||||
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
|
||||
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
|
||||
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }, timeout: TestConstants.settleTimeout)
|
||||
|
||||
let signer = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
@ -312,7 +312,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
viewModel.handleNostrEvent(signed)
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@ -463,7 +463,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -501,7 +501,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -529,7 +529,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didStore = await TestHelpers.waitUntil(
|
||||
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didStore)
|
||||
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
@ -563,7 +563,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
// (sent even for blocked senders) to know processing finished.
|
||||
let didAck = await TestHelpers.waitUntil(
|
||||
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didAck)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
@ -602,7 +602,7 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didUpdate = await TestHelpers.waitUntil(
|
||||
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
|
||||
timeout: 5.0
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(didUpdate)
|
||||
}
|
||||
@ -1022,10 +1022,10 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
// Media sends hop through Task.detached; the global executor is
|
||||
// shared with every parallel test worker, so a loaded runner can
|
||||
// exceed the 5s default. waitUntil returns as soon as the condition
|
||||
// holds, so passing runs never pay the longer timeout.
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
// shared with every parallel test worker, so a loaded runner can be
|
||||
// starved for seconds. waitUntil returns as soon as the condition
|
||||
// holds, so passing runs never pay the settle deadline.
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
@ -1056,7 +1056,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.resolveLegacyPrivateMediaConsent(requestID: firstRequestID, approved: true)
|
||||
let showedSecond = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(showedSecond)
|
||||
let secondRequestID = try #require(viewModel.legacyPrivateMediaConsentRequest?.id)
|
||||
@ -1098,7 +1098,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
)
|
||||
let advanced = await TestHelpers.waitUntil(
|
||||
{ viewModel.legacyPrivateMediaConsentRequest?.peerID == secondPeer },
|
||||
timeout: TestConstants.longTimeout
|
||||
timeout: TestConstants.settleTimeout
|
||||
)
|
||||
#expect(advanced)
|
||||
#expect(decisions.isEmpty, "Invalidation drops the request rather than resolving its send")
|
||||
@ -1128,7 +1128,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
@ -1144,7 +1144,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.settleTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
@ -1165,7 +1165,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
|
||||
@ -429,7 +429,7 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleScreenshotCaptured_privateChatAddsLocalNoticeWithoutSession() async {
|
||||
func handleScreenshotCaptured_privateChatStaysSilentWithoutSession() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
@ -437,8 +437,10 @@ struct ChatViewModelServiceLifecycleTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.handleScreenshotCaptured()
|
||||
|
||||
// No session means no notice went out, so no local echo either —
|
||||
// an echo here would imply Alice was told when she wasn't.
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "you took a screenshot")
|
||||
#expect(viewModel.privateChats[peerID]?.contains { $0.content == "you took a screenshot" } != true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
31
bitchatTests/ConnectivityStatusTests.swift
Normal file
31
bitchatTests/ConnectivityStatusTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -115,13 +115,18 @@ struct PrivateChatE2ETests {
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
|
||||
|
||||
// Manager sessions are keyed by key-derived wire IDs: handshake
|
||||
// completion fails closed on IDs the remote key can't vouch for.
|
||||
let aliceNoiseID = PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
|
||||
let bobNoiseID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
|
||||
// Establish encrypted session
|
||||
do {
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||
let handshake1 = try aliceManager.initiateHandshake(with: bobNoiseID)
|
||||
let handshake2 = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake1)!
|
||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: bobNoiseID, message: handshake2)!
|
||||
_ = try bobManager.handleIncomingHandshake(from: aliceNoiseID, message: handshake3)
|
||||
} catch {
|
||||
Issue.record("Failed to establish Noise session: \(error)")
|
||||
}
|
||||
@ -134,7 +139,7 @@ struct PrivateChatE2ETests {
|
||||
let message = BitchatMessage(packet.payload),
|
||||
message.isPrivate {
|
||||
do {
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||
let encrypted = try aliceManager.encrypt(packet.payload, for: bobNoiseID)
|
||||
let encryptedPacket = BitchatPacket(
|
||||
type: 0x02, // Encrypted message type
|
||||
senderID: packet.senderID,
|
||||
@ -155,7 +160,7 @@ struct PrivateChatE2ETests {
|
||||
// Decrypt incoming encrypted messages
|
||||
if packet.type == 0x02 {
|
||||
do {
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||
let decrypted = try bobManager.decrypt(packet.payload, from: aliceNoiseID)
|
||||
if let message = BitchatMessage(decrypted) {
|
||||
#expect(message.content == TestConstants.testMessage1)
|
||||
#expect(message.isPrivate)
|
||||
|
||||
@ -18,10 +18,10 @@ struct IntegrationTests {
|
||||
private var helper = TestNetworkHelper()
|
||||
|
||||
init() {
|
||||
helper.createNode("Alice", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Bob", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Charlie", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("David", peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode("Alice")
|
||||
helper.createNode("Bob")
|
||||
helper.createNode("Charlie")
|
||||
helper.createNode("David")
|
||||
}
|
||||
|
||||
// MARK: - Multi-Peer Scenarios
|
||||
@ -266,9 +266,11 @@ struct IntegrationTests {
|
||||
helper.nodes["Alice"]!.sendPrivateMessage("Before restart", to: helper.nodes["Bob"]!.peerID, recipientNickname: "Bob")
|
||||
}
|
||||
|
||||
// Simulate Bob restart by recreating his Noise manager
|
||||
// Simulate Bob restart by recreating his Noise manager. A new static
|
||||
// key means a new key-derived wire ID, just like production.
|
||||
let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
helper.noiseManagers["Bob"] = NoiseSessionManager(localStaticKey: bobKey, keychain: helper.mockKeychain)
|
||||
helper.nodes["Bob"]!.myPeerID = PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
@ -320,7 +322,7 @@ struct IntegrationTests {
|
||||
@Test func largeScaleNetwork() async throws {
|
||||
// Create larger network
|
||||
for i in 5...10 {
|
||||
helper.createNode("Node\(i)", peerID: PeerID(str: "PEER\(i)"))
|
||||
helper.createNode("Node\(i)")
|
||||
}
|
||||
|
||||
// Connect in ring topology with cross-connections
|
||||
|
||||
@ -37,7 +37,7 @@ struct LargeTopologyTests {
|
||||
|
||||
private func makeNodes(_ names: [String]) {
|
||||
for name in names {
|
||||
helper.createNode(name, peerID: PeerID(str: UUID().uuidString))
|
||||
helper.createNode(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,15 +22,17 @@ final class TestNetworkHelper {
|
||||
// MARK: - Node/Manager management
|
||||
|
||||
@discardableResult
|
||||
func createNode(_ name: String, peerID: PeerID) -> MockBLEService {
|
||||
func createNode(_ name: String) -> MockBLEService {
|
||||
let node = MockBLEService(bus: bus)
|
||||
node.myPeerID = peerID
|
||||
// Wire IDs must derive from the node's Noise static key: handshake
|
||||
// completion fails closed on IDs the remote key can't vouch for.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
node.myPeerID = PeerID(publicKey: key.publicKey.rawRepresentation)
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
|
||||
// This synchronous helper directly drives all three XX messages and
|
||||
// has no transport callback loop for delayed collision recovery.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(
|
||||
localStaticKey: key,
|
||||
keychain: mockKeychain,
|
||||
|
||||
@ -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: ", "))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -578,6 +578,26 @@ struct NoiseCoverageTests {
|
||||
#expect(failingManager.getSession(for: charliePeerID) == nil)
|
||||
}
|
||||
|
||||
@Test("Handshake completion fails closed on non-wire peer IDs")
|
||||
func handshakeCompletionRejectsNonWirePeerIDs() throws {
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
|
||||
// Alice addresses Bob by an identifier no static key can vouch for:
|
||||
// neither a 16-hex wire ID nor a full Noise-key ID. Completion must
|
||||
// reject it rather than accept any remote static key.
|
||||
let nonWireID = PeerID(str: "not-a-wire-identifier")
|
||||
let msg1 = try aliceManager.initiateHandshake(with: nonWireID)
|
||||
let msg2 = try #require(
|
||||
try bobManager.handleIncomingHandshake(from: bobPeerID, message: msg1)
|
||||
)
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
try aliceManager.handleIncomingHandshake(from: nonWireID, message: msg2)
|
||||
}
|
||||
#expect(aliceManager.getSession(for: nonWireID)?.isEstablished() != true)
|
||||
}
|
||||
|
||||
@Test("Session manager cleans up initiator sessions after start-handshake failures")
|
||||
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
|
||||
let manager = NoiseSessionManager(
|
||||
|
||||
@ -68,22 +68,29 @@ struct NoiseProtocolTests {
|
||||
private let bobKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
private let mockKeychain = MockKeychain()
|
||||
|
||||
private let alicePeerID = PeerID(str: UUID().uuidString)
|
||||
private let bobPeerID = PeerID(str: UUID().uuidString)
|
||||
|
||||
// Manager sessions are keyed by the remote peer. Keep the historical
|
||||
// names, but derive each wire ID from the static key that the
|
||||
// corresponding manager authenticates during the handshake.
|
||||
private var alicePeerID: PeerID {
|
||||
PeerID(publicKey: bobKey.publicKey.rawRepresentation)
|
||||
}
|
||||
private var bobPeerID: PeerID {
|
||||
PeerID(publicKey: aliceKey.publicKey.rawRepresentation)
|
||||
}
|
||||
|
||||
private let aliceSession: NoiseSession
|
||||
private let bobSession: NoiseSession
|
||||
|
||||
|
||||
init() {
|
||||
aliceSession = NoiseSession(
|
||||
peerID: alicePeerID,
|
||||
peerID: PeerID(publicKey: bobKey.publicKey.rawRepresentation),
|
||||
role: .initiator,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: aliceKey
|
||||
)
|
||||
|
||||
|
||||
bobSession = NoiseSession(
|
||||
peerID: bobPeerID,
|
||||
peerID: PeerID(publicKey: aliceKey.publicKey.rawRepresentation),
|
||||
role: .responder,
|
||||
keychain: mockKeychain,
|
||||
localStaticKey: bobKey
|
||||
|
||||
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal file
32
bitchatTests/NostrInboundPipelineTimestampTests.swift
Normal file
@ -0,0 +1,32 @@
|
||||
//
|
||||
// NostrInboundPipelineTimestampTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct NostrInboundPipelineTimestampTests {
|
||||
private let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
private let nowSeconds = 1_700_000_000
|
||||
private let skew = Int(TransportConfig.nostrDMMaxClockSkewSeconds)
|
||||
private let lookback = Int(TransportConfig.nostrDMSubscribeLookbackSeconds)
|
||||
|
||||
@Test("Rumor timestamps inside the lookback-plus-skew window are accepted")
|
||||
func acceptsPlausibleTimestamps() {
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds, now: now))
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback + 60, now: now))
|
||||
// A sender clock slightly ahead of the receiver is tolerated.
|
||||
#expect(NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew - 60, now: now))
|
||||
}
|
||||
|
||||
@Test("Future-dated and stale rumor timestamps are rejected")
|
||||
func rejectsImplausibleTimestamps() {
|
||||
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds + skew + 60, now: now))
|
||||
#expect(!NostrInboundPipeline.isPlausibleRumorTimestamp(nowSeconds - lookback - skew - 60, now: now))
|
||||
}
|
||||
}
|
||||
@ -150,14 +150,12 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.clearedSessions.append(peerID)
|
||||
service.clearSession(for: peerID)
|
||||
},
|
||||
handleAuthenticatedPeerState: {
|
||||
peerID, payload, generation in
|
||||
handleAuthenticatedPeerState: { peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append(
|
||||
(peerID, payload, generation)
|
||||
)
|
||||
},
|
||||
deliverNoisePayload: {
|
||||
peerID, type, payload, timestamp in
|
||||
deliverNoisePayload: { peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append(
|
||||
(peerID, type, payload, timestamp)
|
||||
)
|
||||
|
||||
@ -39,6 +39,19 @@ final class VerificationServiceTests: XCTestCase {
|
||||
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsFutureDatedPayload() throws {
|
||||
let (service, noise) = makeService()
|
||||
let futureTimestamp = Int64(Date().addingTimeInterval(3600).timeIntervalSince1970)
|
||||
let qrString = try makeSignedQR(
|
||||
noise: noise,
|
||||
nickname: "future-\(UUID().uuidString)",
|
||||
npub: nil,
|
||||
ts: futureTimestamp
|
||||
)
|
||||
|
||||
XCTAssertNil(service.verifyScannedQR(qrString, maxAge: 60))
|
||||
}
|
||||
|
||||
func test_verifyScannedQR_rejectsTamperedSignature() throws {
|
||||
let (service, noise) = makeService()
|
||||
let badSignature = Data(repeating: 0xAA, count: 64)
|
||||
|
||||
@ -56,7 +56,21 @@ struct SimulatedMeshTests {
|
||||
mesh.connect(1, 2)
|
||||
|
||||
mesh.announceAll()
|
||||
mesh.advanceTime(by: 2)
|
||||
// Discovery is not quiet after one advance: every first-seen peer
|
||||
// schedules an afterglow re-announce at a RANDOM 0.3–0.6s delay
|
||||
// (BLEAnnounceHandler), and each of those can cascade another relay
|
||||
// round. Whether that traffic lands before or after a one-shot
|
||||
// baseline snapshot depends on the draw — the budget assertion below
|
||||
// flaked on CI at 14 and 18 frames for exactly that reason. Advance
|
||||
// until the mesh goes a full window with no new frames, so the
|
||||
// baseline only ever measures the message under test.
|
||||
var settled = mesh.deliveredFrameCount
|
||||
for _ in 0..<20 {
|
||||
mesh.advanceTime(by: 2)
|
||||
let now = mesh.deliveredFrameCount
|
||||
if now == settled { break }
|
||||
settled = now
|
||||
}
|
||||
let baseline = mesh.deliveredFrameCount
|
||||
|
||||
let capture = TransportEventCapture()
|
||||
|
||||
@ -8,6 +8,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
// periphery:ignore - intentionally unreferenced by production code; nothing
|
||||
// emits or consumes this type yet, and BLEService parses it only to ignore it.
|
||||
// Delete this annotation when the mesh starts using it.
|
||||
/// Identity-free presence announcement for rotating peer IDs.
|
||||
///
|
||||
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 4–30 seconds: the
|
||||
@ -37,9 +40,6 @@ import Foundation
|
||||
/// `docs/PEER-ID-ROTATION.md`.
|
||||
///
|
||||
/// Not emitted or consumed by the shipping mesh yet.
|
||||
// periphery:ignore - intentionally unreferenced by production code; nothing
|
||||
// emits or consumes this type yet, and BLEService parses it only to ignore it.
|
||||
// Delete this annotation when the mesh starts using it.
|
||||
public struct AnnounceV2Packet: Equatable, Sendable {
|
||||
/// Rotation epoch this announce was built for. Carried explicitly so a
|
||||
/// receiver matches against a stated epoch instead of guessing.
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
import Foundation
|
||||
private import CryptoKit
|
||||
|
||||
// periphery:ignore - intentionally unreferenced by production code. These are
|
||||
// the reviewable primitives for a protocol change that cannot ship until both
|
||||
// platforms agree on it; wiring them into the transport is the next step, not
|
||||
// this one. Delete this annotation when the mesh starts using them.
|
||||
/// Derivations for rotating peer IDs and pairwise recognition tags.
|
||||
///
|
||||
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
|
||||
@ -32,10 +36,6 @@ private import CryptoKit
|
||||
/// Recognition tags come from the X25519 shared secret between two static
|
||||
/// keys, so exactly two parties can compute a given tag and an observer can
|
||||
/// compute none of them.
|
||||
// periphery:ignore - intentionally unreferenced by production code. These are
|
||||
// the reviewable primitives for a protocol change that cannot ship until both
|
||||
// platforms agree on it; wiring them into the transport is the next step, not
|
||||
// this one. Delete this annotation when the mesh starts using them.
|
||||
public enum PeerIDRotation {
|
||||
// MARK: - Parameters
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user