mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:05:19 +00:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02828731b9 | ||
|
|
f5b2150f11 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
b28fb26504 | ||
|
|
68eeba97ff | ||
|
|
ba0861a446 | ||
|
|
424c47be82 | ||
|
|
20c1c0f687 | ||
|
|
ebdcdf83dd | ||
|
|
2c04f65c83 | ||
|
|
293380e671 | ||
|
|
5aaa209020 |
@@ -12,7 +12,10 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||
# default (observed: intermittent app-suite hangs starving the queue).
|
||||
timeout-minutes: 15
|
||||
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||
timeout-minutes: 25
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
@@ -29,17 +32,22 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||
# supported by the compiler").
|
||||
- name: Note toolchain version (cache key)
|
||||
id: swift-version
|
||||
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||
|
||||
- name: Build tests
|
||||
# Built separately so the hang watchdog below times only test
|
||||
@@ -97,9 +105,14 @@ jobs:
|
||||
|
||||
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||
# catches algorithmic regressions, never runner variance.
|
||||
# catches algorithmic regressions, never runner variance. If a metric
|
||||
# still lands below floor (a saturated runner can dip one), the script
|
||||
# re-runs the benchmarks — appending to the same log and keeping each
|
||||
# benchmark's best value per metric — so noise clears on retry while a
|
||||
# real regression fails every attempt. Floors are never lowered by this.
|
||||
- name: Performance floor gate
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
@@ -140,3 +153,27 @@ jobs:
|
||||
ARCHS=arm64 \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
# privilege we can: a read-only token, and no credentials left in the
|
||||
# checkout for the container to find.
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
- build
|
||||
- localPackages/*/.build
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- type_name
|
||||
- identifier_name
|
||||
- statement_position
|
||||
- implicit_optional_initialization
|
||||
- force_try
|
||||
- vertical_whitespace
|
||||
- for_where
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- type_body_length
|
||||
- file_length
|
||||
- large_tuple
|
||||
- force_cast
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
+2
-2
@@ -13,9 +13,9 @@ let package = Package(
|
||||
.executable(
|
||||
name: "bitchat",
|
||||
targets: ["bitchat"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies:[
|
||||
dependencies: [
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
|
||||
@@ -49,6 +49,14 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
/// Resends a failed private message through the normal send path,
|
||||
/// removing the failed original so the re-submission replaces it
|
||||
/// instead of stacking a duplicate under the red bubble.
|
||||
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||
chatViewModel.removePrivateMessage(withID: message.id)
|
||||
chatViewModel.sendMessage(message.content)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
@@ -67,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||
// Mesh: block the peer's stable Noise identity resolved from the
|
||||
// tapped peerID rather than re-resolving a display-name string.
|
||||
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||
/// cannot resolve.
|
||||
func unblock(peerID: PeerID, displayName: String) {
|
||||
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
|
||||
@@ -232,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
@@ -71,7 +71,7 @@ struct BitchatApp: App {
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
|
||||
}
|
||||
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown = "unknown"
|
||||
case casual = "casual"
|
||||
case trusted = "trusted"
|
||||
case verified = "verified"
|
||||
case unknown
|
||||
case casual
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
|
||||
+911
-154
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,19 @@ import Foundation
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||
// the suggestion panel is the app's only command-discovery surface, and
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case help
|
||||
case hug
|
||||
case message = "dm"
|
||||
case message = "msg"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -29,7 +33,7 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -38,6 +42,7 @@ enum CommandInfo: String, Identifiable {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
@@ -49,10 +54,12 @@ enum CommandInfo: String, Identifiable {
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// The processor rejects favorites in geohash contexts, so only
|
||||
// suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
||||
case XX // Most versatile, mutual authentication
|
||||
case IK // Initiator knows responder's static key
|
||||
case NK // Anonymous initiator
|
||||
case X // One-way: single message to a known static key (no response)
|
||||
}
|
||||
|
||||
enum NoiseRole {
|
||||
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
|
||||
switch pattern {
|
||||
case .XX:
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
case .IK, .NK, .X:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
@@ -904,6 +905,7 @@ extension NoisePattern {
|
||||
case .XX: return "XX"
|
||||
case .IK: return "IK"
|
||||
case .NK: return "NK"
|
||||
case .X: return "X"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +927,10 @@ extension NoisePattern {
|
||||
[.e, .es], // -> e, es
|
||||
[.e, .ee] // <- e, ee
|
||||
]
|
||||
case .X:
|
||||
return [
|
||||
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +648,7 @@ private extension NostrProtocol {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data("nip44-v2".utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -132,4 +132,3 @@ private extension Data {
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let scheduleAfterglow: (TimeInterval) -> Void
|
||||
}
|
||||
|
||||
/// Outcome of an accepted announce, surfaced so the service can run
|
||||
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||
struct BLEAnnounceHandlingResult {
|
||||
let peerID: PeerID
|
||||
let announcement: AnnouncementPacket
|
||||
let isDirectAnnounce: Bool
|
||||
let isVerified: Bool
|
||||
}
|
||||
|
||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||
/// gossip tracking, and the reciprocal announce response.
|
||||
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||
let env = environment
|
||||
let now = env.now()
|
||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
|
||||
announcement = acceptance.announcement
|
||||
case .reject(.malformed):
|
||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
return nil
|
||||
case .reject(.senderMismatch(let derivedFromKey)):
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
return nil
|
||||
case .reject(.selfAnnounce):
|
||||
return
|
||||
return nil
|
||||
case .reject(.stale(let ageSeconds)):
|
||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Suppress announce logs to reduce noise
|
||||
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
|
||||
let delay = Double.random(in: 0.3...0.6)
|
||||
env.scheduleAfterglow(delay)
|
||||
}
|
||||
|
||||
return BLEAnnounceHandlingResult(
|
||||
peerID: peerID,
|
||||
announcement: announcement,
|
||||
isDirectAnnounce: isDirectAnnounce,
|
||||
isVerified: verifiedAnnounce
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
allowedLinks(
|
||||
let rawAllowed = allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
),
|
||||
)
|
||||
|
||||
if let directedPeerHint,
|
||||
let directedSelection = directLinks(
|
||||
to: directedPeerHint,
|
||||
links: rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return directedSelection
|
||||
}
|
||||
if let directedPeerHint,
|
||||
hasBoundLink(
|
||||
to: directedPeerHint,
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||
}
|
||||
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func directLinks(
|
||||
to peerID: PeerID,
|
||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> BLEFanoutSelection? {
|
||||
let directLinks = collapseDuplicateLinksPerPeer(
|
||||
(
|
||||
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
|
||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(directLinks.peripheralIDs),
|
||||
centralIDs: Set(directLinks.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private static func hasBoundLink(
|
||||
to peerID: PeerID,
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> Bool {
|
||||
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|
||||
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
|
||||
}
|
||||
|
||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,20 @@ final class BLEService: NSObject {
|
||||
|
||||
// 4. Efficient Message Deduplication
|
||||
private let messageDeduplicator = MessageDeduplicator()
|
||||
|
||||
// Courier store-and-forward: envelopes this device carries for offline
|
||||
// third parties, and the trust gate for accepting deposits. Injectable
|
||||
// for tests; main-actor policy because favorites live on the main actor.
|
||||
var courierStore: CourierStore = .shared
|
||||
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||
// packets between in-process service instances.
|
||||
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
|
||||
@@ -889,6 +903,10 @@ final class BLEService: NSObject {
|
||||
packetToSend = packet
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
_test_onOutboundPacket?(packetToSend)
|
||||
#endif
|
||||
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||||
if packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
@@ -1047,6 +1065,9 @@ final class BLEService: NSObject {
|
||||
|
||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||
#if DEBUG
|
||||
_test_onOutboundPacket?(packet)
|
||||
#endif
|
||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||
}
|
||||
@@ -1305,7 +1326,7 @@ extension BLEService: GossipSyncManager.Delegate {
|
||||
|
||||
extension BLEService: CBCentralManagerDelegate {
|
||||
#if os(iOS)
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
|
||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||
@@ -1987,7 +2008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
|
||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||
|
||||
@@ -2469,6 +2490,141 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Courier Store-and-Forward
|
||||
|
||||
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||
/// the envelope to the given couriers for physical delivery. Returns false
|
||||
/// when no courier is connected, the payload cannot be built, or sealing
|
||||
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
let connected = couriers.filter { isPeerConnected($0) }
|
||||
guard !connected.isEmpty,
|
||||
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let payload: Data
|
||||
do {
|
||||
let now = Date()
|
||||
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientNoiseKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
} catch {
|
||||
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
|
||||
return false
|
||||
}
|
||||
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
for courier in connected {
|
||||
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||
/// recipient (the rotating tag matches our static key → open and deliver)
|
||||
/// or courier (a trusted peer is depositing mail for someone else → store).
|
||||
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Directed packets only; envelopes addressed elsewhere ride the
|
||||
// generic relay path untouched.
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
|
||||
|
||||
let myKey = noiseService.getStaticPublicKeyData()
|
||||
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||
openCourierEnvelope(envelope)
|
||||
} else {
|
||||
acceptCourierDeposit(envelope, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||
do {
|
||||
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
guard let typeRaw = typedPayload.first,
|
||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||
payloadType == .privateMessage else {
|
||||
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||||
return
|
||||
}
|
||||
// Couriered mail arrives while the sender is absent, so the UI's
|
||||
// block check can't resolve their fingerprint from a live session.
|
||||
// Gate here, where the full static key is in hand.
|
||||
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||||
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||
return
|
||||
}
|
||||
// Mesh peer IDs are derived from Noise static keys, so the sender
|
||||
// resolves to the same DM thread whether or not they're present.
|
||||
let senderPeerID = PeerID(publicKey: senderStaticKey)
|
||||
let payload = Data(typedPayload.dropFirst())
|
||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: senderPeerID,
|
||||
type: payloadType,
|
||||
payload: payload,
|
||||
timestamp: Date()
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
// Tag collision or stale key: not addressed to us after all.
|
||||
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
||||
}
|
||||
}
|
||||
|
||||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
|
||||
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
|
||||
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||||
return
|
||||
}
|
||||
let store = courierStore
|
||||
let policy = courierDepositPolicy
|
||||
Task { @MainActor in
|
||||
guard policy(depositorKey) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
|
||||
return
|
||||
}
|
||||
if store.deposit(envelope, from: depositorKey) {
|
||||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
||||
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
||||
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
|
||||
guard !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||
|
||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||
@@ -2700,7 +2856,7 @@ extension BLEService {
|
||||
|
||||
// Notify delegate of failure
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed")))
|
||||
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2954,12 +3110,14 @@ extension BLEService {
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
case .none:
|
||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||
break
|
||||
}
|
||||
|
||||
if forwardAlongRouteIfNeeded(packet) {
|
||||
@@ -3016,7 +3174,18 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
announceHandler.handle(packet, from: peerID)
|
||||
let result = announceHandler.handle(packet, from: peerID)
|
||||
|
||||
// Courier handover: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them.
|
||||
// Direct announces only: envelopes are removed from the store
|
||||
// optimistically, so handover must ride an established link rather
|
||||
// than a speculative multi-hop send toward a relayed announce.
|
||||
guard !courierStore.isEmpty,
|
||||
let result,
|
||||
result.isVerified,
|
||||
result.isDirectAnnounce else { return }
|
||||
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
||||
}
|
||||
|
||||
/// Builds the announce handler environment. All queue hops stay here so
|
||||
|
||||
@@ -105,11 +105,28 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only command reference, printed as a system message. The
|
||||
/// suggestion panel hides once arguments are typed, and typos used to
|
||||
/// dead-end in a bare "unknown command" — this is the way out.
|
||||
static 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)
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// CourierStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||
///
|
||||
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
||||
/// never learns sender, recipient, or content. Strict quotas keep the device
|
||||
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
||||
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
||||
/// retention policy. Carried mail is included in the panic wipe.
|
||||
final class CourierStore {
|
||||
struct StoredEnvelope: Codable, Equatable {
|
||||
let recipientTag: Data
|
||||
let expiry: UInt64
|
||||
let ciphertext: Data
|
||||
let depositorNoiseKey: Data
|
||||
let storedAt: Date
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
enum Limits {
|
||||
static let maxEnvelopes = 20
|
||||
static let maxPerDepositor = 5
|
||||
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||
}
|
||||
|
||||
static let shared = CourierStore()
|
||||
|
||||
/// Number of envelopes currently carried, published on the main thread
|
||||
/// so the UI can show a "carrying mail" indicator.
|
||||
@Published private(set) var carriedCount: Int = 0
|
||||
|
||||
/// Fast path so hot code (announce handling) can skip tag computation.
|
||||
var isEmpty: Bool {
|
||||
queue.sync { envelopes.isEmpty }
|
||||
}
|
||||
|
||||
private var envelopes: [StoredEnvelope] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Depositing (courier side)
|
||||
|
||||
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||
/// validity checks reject it. Trust policy (mutual favorite) is the
|
||||
/// caller's responsibility; this store only enforces resource bounds.
|
||||
@discardableResult
|
||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
|
||||
let date = now()
|
||||
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||
!envelope.ciphertext.isEmpty,
|
||||
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
|
||||
!envelope.isExpired(at: date) else {
|
||||
return false
|
||||
}
|
||||
// Reject expiries beyond the policy lifetime so depositors can't pin
|
||||
// storage longer than the outbox would retain the message itself.
|
||||
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
|
||||
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
|
||||
// Identical ciphertext is the same envelope; accept idempotently.
|
||||
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
return true
|
||||
}
|
||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
||||
return false
|
||||
}
|
||||
if envelopes.count >= Limits.maxEnvelopes {
|
||||
// Oldest-first eviction, matching outbox overflow behavior.
|
||||
let evicted = envelopes.removeFirst()
|
||||
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
||||
}
|
||||
|
||||
envelopes.append(StoredEnvelope(
|
||||
recipientTag: envelope.recipientTag,
|
||||
expiry: envelope.expiry,
|
||||
ciphertext: envelope.ciphertext,
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handover (on encountering a peer)
|
||||
|
||||
/// Remove and return all envelopes addressed to the given peer, matching
|
||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||
/// optimistically: handover happens over a live link, and the depositor's
|
||||
/// outbox still retains the original for direct delivery.
|
||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||
guard !matched.isEmpty else { return [] }
|
||||
envelopes.removeAll { stored in matched.contains(stored) }
|
||||
persistLocked()
|
||||
return matched.map(\.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all carried mail from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func pruneExpiredLocked(at date: Date) {
|
||||
let before = envelopes.count
|
||||
envelopes.removeAll { $0.envelope.isExpired(at: date) }
|
||||
if envelopes.count != before {
|
||||
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishCountLocked() {
|
||||
let count = envelopes.count
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.carriedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
publishCountLocked()
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if envelopes.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(envelopes)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||
return
|
||||
}
|
||||
envelopes = stored
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("courier", isDirectory: true)
|
||||
.appendingPathComponent("envelopes.json")
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,33 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Trust and identity lookups the router needs to pick couriers. Backed by
|
||||
/// the favorites store in production; injectable for tests.
|
||||
struct CourierDirectory {
|
||||
/// Noise static key for a peer we can address while they're offline.
|
||||
var noiseKey: (PeerID) -> Data?
|
||||
/// Whether a peer (by Noise static key) may carry our mail.
|
||||
var isTrustedCourier: (Data) -> Bool
|
||||
|
||||
@MainActor
|
||||
static func favoritesBacked() -> CourierDirectory {
|
||||
CourierDirectory(
|
||||
noiseKey: { peerID in
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||
},
|
||||
isTrustedCourier: { noiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
|
||||
/// Invoked whenever a retained private message is dropped without a
|
||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||
@@ -14,6 +36,12 @@ final class MessageRouter {
|
||||
/// stale "sending/sent" state forever.
|
||||
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
/// Invoked when a message with no reachable transport was handed to at
|
||||
/// least one courier (a connected mutual favorite who will physically
|
||||
/// carry the sealed envelope). Delivery stays best-effort: the outbox
|
||||
/// retains the message until an ack arrives.
|
||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
@@ -31,10 +59,17 @@ final class MessageRouter {
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
private static let maxSendAttempts = 8
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
|
||||
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
|
||||
init(
|
||||
transports: [Transport],
|
||||
now: @escaping () -> Date = Date.init,
|
||||
courierDirectory: CourierDirectory? = nil
|
||||
) {
|
||||
self.transports = transports
|
||||
self.now = now
|
||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -51,7 +86,7 @@ final class MessageRouter {
|
||||
}
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
note.userInfo?["isKeyUpdate"] is Bool {
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
@@ -94,6 +129,33 @@ final class MessageRouter {
|
||||
unsent.sendAttempts = 0
|
||||
enqueue(unsent, for: peerID)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Last resort when no transport can reach the peer: seal the message to
|
||||
/// their known static key and hand it to connected mutual favorites who
|
||||
/// may physically encounter them. The queued copy above stays retained,
|
||||
/// so direct delivery still wins if the peer reappears first (receivers
|
||||
/// dedup by message ID).
|
||||
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||
for transport in transports {
|
||||
let couriers = transport.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
guard snapshot.isConnected,
|
||||
let key = snapshot.noisePublicKey,
|
||||
key != recipientKey else { return false }
|
||||
return courierDirectory.isTrustedCourier(key)
|
||||
}
|
||||
.prefix(Self.maxCouriersPerMessage)
|
||||
.map(\.peerID)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
onMessageCarried?(messageID, peerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -370,6 +370,49 @@ final class NoiseEncryptionService {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
// MARK: - Courier Envelopes (one-way Noise X)
|
||||
|
||||
/// Domain separation for courier envelopes so X-pattern transcripts can
|
||||
/// never be confused with interactive XX handshakes.
|
||||
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
|
||||
|
||||
/// Encrypt a payload to a peer's known static key without an interactive
|
||||
/// handshake (Noise X pattern). Used for store-and-forward envelopes
|
||||
/// carried by couriers while the recipient is offline.
|
||||
/// - Warning: One-way messages have no forward secrecy: a later compromise
|
||||
/// of the recipient's static key exposes envelopes captured in transit.
|
||||
/// Use established sessions whenever the peer is reachable.
|
||||
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt a courier envelope addressed to our static key. Returns the
|
||||
/// payload and the sender's authenticated static public key (the `ss`
|
||||
/// DH in the X pattern binds the sender's identity to the ciphertext).
|
||||
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
// Clear from keychain
|
||||
@@ -428,7 +471,7 @@ final class NoiseEncryptionService {
|
||||
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||
var out = Data()
|
||||
// context
|
||||
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||
let context = Data("bitchat-announce-v1".utf8)
|
||||
out.append(UInt8(min(context.count, 255)))
|
||||
out.append(context.prefix(255))
|
||||
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||
@@ -444,7 +487,7 @@ final class NoiseEncryptionService {
|
||||
out.append(ed32)
|
||||
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
||||
// nickname length + bytes
|
||||
let nickData = nickname.data(using: .utf8) ?? Data()
|
||||
let nickData = Data(nickname.utf8)
|
||||
out.append(UInt8(min(nickData.count, 255)))
|
||||
out.append(nickData.prefix(255))
|
||||
// timestamp
|
||||
|
||||
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
|
||||
@@ -111,7 +111,7 @@ final class NotificationService {
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
|
||||
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ protocol Transport: AnyObject {
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Courier store-and-forward (mesh transports only): seal a message to the
|
||||
// recipient's static key and hand it to connected couriers for physical
|
||||
// delivery while the recipient is offline. Returns false when the
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -120,6 +126,7 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -258,6 +258,28 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
return false
|
||||
}
|
||||
|
||||
/// Block or unblock a mesh peer by its stable Noise identity.
|
||||
///
|
||||
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
|
||||
/// (cache / mesh session / known-peer Noise key). This works even when the
|
||||
/// peer is offline — including offline favorites — so the exact tapped peer
|
||||
/// is (un)blocked unambiguously instead of being re-resolved by a
|
||||
/// display-name string that two peers could share.
|
||||
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
|
||||
@discardableResult
|
||||
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: blocked)
|
||||
updatePeers()
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
|
||||
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
|
||||
case .failed: return 1
|
||||
case .sending: return 2
|
||||
case .sent: return 3
|
||||
case .partiallyDelivered: return 4
|
||||
case .delivered: return 5
|
||||
case .read: return 6
|
||||
case .carried: return 4
|
||||
case .partiallyDelivered: return 5
|
||||
case .delivered: return 6
|
||||
case .read: return 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
|
||||
key: Data?
|
||||
) {
|
||||
guard let context else { return }
|
||||
if let _ = key {
|
||||
if key != nil {
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
|
||||
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if let _ = key {
|
||||
if key != nil {
|
||||
if let identity = context.currentNostrIdentity() {
|
||||
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||
}
|
||||
|
||||
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
}
|
||||
|
||||
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
|
||||
// command, these resolve and persist the block by the peer's stable
|
||||
// fingerprint (derived from `peerID`), so the exact tapped peer is
|
||||
// (un)blocked — unambiguous across nickname collisions and functional for
|
||||
// offline peers that can no longer be resolved through the mesh service.
|
||||
@MainActor
|
||||
func blockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unblockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
|
||||
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
|
||||
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
|
||||
comment: "System message shown when a mesh peer is blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||
}
|
||||
@@ -1147,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
// Drop courier mail carried for third parties (memory and disk)
|
||||
CourierStore.shared.wipe()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
@@ -1435,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
/// Processes IRC-style commands starting with '/'.
|
||||
/// - Parameter command: The full command string including the leading slash
|
||||
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
|
||||
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
|
||||
@MainActor
|
||||
func handleCommand(_ command: String) {
|
||||
let result = commandProcessor.process(command)
|
||||
@@ -1443,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
switch result {
|
||||
case .success(let message):
|
||||
if let msg = message {
|
||||
addSystemMessage(msg)
|
||||
addCommandOutput(msg)
|
||||
}
|
||||
case .error(let message):
|
||||
addSystemMessage(message)
|
||||
addCommandOutput(message)
|
||||
case .handled:
|
||||
// Command was handled, no message needed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Command output belongs in the conversation where the user typed the
|
||||
/// command; the public timeline is invisible while a DM is open. The DM
|
||||
/// selection is read *after* processing so commands that switch chats
|
||||
/// (`/msg`) print into the conversation they just opened.
|
||||
@MainActor
|
||||
private func addCommandOutput(_ content: String) {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
addLocalPrivateSystemMessage(content, to: peerID)
|
||||
} else {
|
||||
addSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Reception
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -100,11 +100,28 @@ private extension ChatViewModelBootstrapper {
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(
|
||||
.failed(reason: "Not delivered"),
|
||||
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
forMessageID: messageID
|
||||
)
|
||||
}
|
||||
}
|
||||
// A message with no reachable transport that was handed to a courier
|
||||
// shows a distinct "carried" state instead of sitting in "sending"
|
||||
// forever. Never downgrade a confirmed receipt: the courier copy can
|
||||
// race direct delivery when the peer reappears.
|
||||
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
|
||||
guard let viewModel else { return }
|
||||
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
|
||||
case .delivered, .read:
|
||||
break
|
||||
default:
|
||||
SecureLogger.debug(
|
||||
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
|
||||
}
|
||||
}
|
||||
viewModel.commandProcessor.contextProvider = viewModel
|
||||
viewModel.commandProcessor.meshService = viewModel.meshService
|
||||
viewModel.participantTracker.configure(context: viewModel)
|
||||
|
||||
@@ -55,6 +55,26 @@ struct AppInfoView: View {
|
||||
)
|
||||
}
|
||||
|
||||
enum Legend {
|
||||
static let title: LocalizedStringKey = "app_info.legend.title"
|
||||
/// Every glyph the peer lists and headers use, in one place —
|
||||
/// nothing else in the app defines them.
|
||||
static let items: [(icon: String, text: LocalizedStringKey)] = [
|
||||
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
|
||||
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
|
||||
("globe", "app_info.legend.nostr"),
|
||||
("person", "app_info.legend.offline"),
|
||||
("mappin.and.ellipse", "app_info.legend.location_nearby"),
|
||||
("face.dashed", "app_info.legend.teleported"),
|
||||
("lock.fill", "app_info.legend.encrypted"),
|
||||
("lock.slash", "app_info.legend.encryption_failed"),
|
||||
("checkmark.seal.fill", "app_info.legend.verified"),
|
||||
("star.fill", "app_info.legend.favorite"),
|
||||
("envelope.fill", "app_info.legend.unread"),
|
||||
("nosign", "app_info.legend.blocked")
|
||||
]
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
@@ -118,14 +138,8 @@ struct AppInfoView: View {
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("app_info.close")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +216,28 @@ struct AppInfoView: View {
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
}
|
||||
|
||||
// Symbols legend
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(Strings.Legend.title)
|
||||
|
||||
ForEach(Strings.Legend.items, id: \.icon) { item in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: item.icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
Text(item.text)
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
|
||||
@@ -14,11 +14,25 @@ struct CommandSuggestionsView: View {
|
||||
|
||||
@Binding var messageText: String
|
||||
|
||||
/// The command already typed in full, once arguments have begun.
|
||||
private var typedCommandAlias: String? {
|
||||
guard messageText.hasPrefix("/"),
|
||||
let spaceIndex = messageText.firstIndex(of: " ")
|
||||
else { return nil }
|
||||
return String(messageText[..<spaceIndex]).lowercased()
|
||||
}
|
||||
|
||||
private var filteredCommands: [CommandInfo] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
guard messageText.hasPrefix("/") else { return [] }
|
||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
|
||||
// While arguments are being typed, keep the matched command's usage
|
||||
// row visible instead of vanishing at the first space.
|
||||
if let typed = typedCommandAlias {
|
||||
return commands.filter { $0.alias == typed && $0.placeholder != nil }
|
||||
}
|
||||
return commands.filter { command in
|
||||
command.alias.starts(with: messageText.lowercased())
|
||||
}
|
||||
}
|
||||
@@ -28,15 +42,18 @@ struct CommandSuggestionsView: View {
|
||||
// still receive the composer VStack's spacing and push the input row
|
||||
// off-center.
|
||||
if !filteredCommands.isEmpty {
|
||||
let isUsageReminder = typedCommandAlias != nil
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(filteredCommands) { command in
|
||||
Button {
|
||||
// In usage-reminder mode the row is informational; an
|
||||
// insert here would wipe the arguments being typed.
|
||||
guard !isUsageReminder else { return }
|
||||
messageText = command.alias + " "
|
||||
} label: {
|
||||
buttonRow(for: command)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.themedOverlayPanel()
|
||||
|
||||
@@ -9,6 +9,47 @@
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
extension DeliveryStatus {
|
||||
/// Localized, user-facing description of the status. Used for macOS
|
||||
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver —
|
||||
/// the glyphs alone are unexplained 10pt icons.
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .read(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
case .failed(let reason):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@ThemedPalette private var palette
|
||||
let status: DeliveryStatus
|
||||
@@ -19,44 +60,17 @@ struct DeliveryStatusView: View {
|
||||
|
||||
private var secondaryTextColor: Color { palette.secondary }
|
||||
|
||||
private enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
total
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
var body: some View {
|
||||
statusGlyph
|
||||
.help(status.bitchatDescription)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(status.bitchatDescription)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusGlyph: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
@@ -68,7 +82,12 @@ struct DeliveryStatusView: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
case .carried:
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
|
||||
case .delivered:
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -76,23 +95,21 @@ struct DeliveryStatusView: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
case .read:
|
||||
// Filled variant so read vs delivered is legible without color.
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundColor(palette.accentBlue)
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
@@ -102,7 +119,6 @@ struct DeliveryStatusView: View {
|
||||
.bitchatFont(size: 10)
|
||||
}
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
.help(Strings.deliveredToMembers(reached, total))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +127,7 @@ struct DeliveryStatusView: View {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
|
||||
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
|
||||
|
||||
private var fgColor: Color { palette.primary }
|
||||
private var bgColor: Color {
|
||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SheetCloseButton.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The close "X" every sheet and header shares. One glyph size and weight
|
||||
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
|
||||
/// so existing header metrics don't move, and a hit target extended to 44pt
|
||||
/// per platform guidelines. Tint comes from the environment, so callers keep
|
||||
/// their own foreground color.
|
||||
struct SheetCloseButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import BitFoundation
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
|
||||
let message: BitchatMessage
|
||||
@@ -24,6 +25,7 @@ struct TextMessageView: View {
|
||||
/// the enum makes the change visible to SwiftUI's structural diff.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
@State private var expandedMessageIDs: Set<String> = []
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
init(message: BitchatMessage) {
|
||||
self.message = message
|
||||
@@ -35,19 +37,59 @@ struct TextMessageView: View {
|
||||
// Precompute heavy token scans once per row
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Baseline alignment keeps the lock and delivery glyphs on the
|
||||
// first text line; a fixed top padding left the lock's solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.orange.opacity(0.75))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
// Delivery status indicator for private messages. Tappable:
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +102,7 @@ struct TextMessageView: View {
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
.foregroundColor(Color.blue)
|
||||
.foregroundColor(palette.accentBlue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
@@ -78,6 +120,12 @@ struct TextMessageView: View {
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
// Collapse the revealed caption when the status advances (e.g.
|
||||
// sending → sent → delivered) so a detail opened for one state
|
||||
// doesn't linger and silently morph into another.
|
||||
.onChange(of: deliveryStatus) { _ in
|
||||
showDeliveryDetail = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import UIKit
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
|
||||
@@ -43,7 +44,6 @@ struct ContentComposerView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
}
|
||||
}
|
||||
.themedOverlayPanel()
|
||||
@@ -60,9 +60,7 @@ struct ContentComposerView: View {
|
||||
TextField(
|
||||
"",
|
||||
text: $messageText,
|
||||
prompt: Text(
|
||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
||||
)
|
||||
prompt: Text(placeholderText)
|
||||
.foregroundColor(palette.secondary.opacity(0.6))
|
||||
)
|
||||
.textFieldStyle(.plain)
|
||||
@@ -110,6 +108,33 @@ struct ContentComposerView: View {
|
||||
}
|
||||
|
||||
private extension ContentComposerView {
|
||||
/// States where a message will land: the DM partner's name for private
|
||||
/// chats, the channel (and its public nature) otherwise — so a stressed
|
||||
/// user never has to guess who can read what they're typing.
|
||||
var placeholderText: String {
|
||||
if let header = privateConversationModel.selectedHeaderState {
|
||||
// A geohash-DM display name already carries its own "#geohash/@name"
|
||||
// form, so it must not get another "@" prefix; a mesh nickname does.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
locale: .current,
|
||||
target
|
||||
)
|
||||
}
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||
case .location(let channel):
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var recordingIndicator: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
@@ -158,7 +183,19 @@ private extension ContentComposerView {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel("Tap for library, long press for camera")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// The long-press → camera path is unreachable for VoiceOver users;
|
||||
// mirror it as a named action.
|
||||
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
#else
|
||||
Button(action: { showMacImagePicker = true }) {
|
||||
Image(systemName: "photo.circle.fill")
|
||||
@@ -167,7 +204,9 @@ private extension ContentComposerView {
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Choose photo")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -209,7 +248,27 @@ private extension ContentComposerView {
|
||||
}
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Hold to record a voice note")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||
)
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
: ""
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||
// give it a start/stop toggle as the default action.
|
||||
.accessibilityAction {
|
||||
if voiceRecordingVM.state.isActive {
|
||||
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
|
||||
} else {
|
||||
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendButtonView(enabled: Bool) -> some View {
|
||||
|
||||
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
|
||||
let headerPeerIconSize: CGFloat
|
||||
let headerPeerCountFontSize: CGFloat
|
||||
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "bitchat/")
|
||||
@@ -33,6 +36,16 @@ struct ContentHeaderView: View {
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
// 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.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
)
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "@")
|
||||
@@ -78,6 +91,23 @@ struct ContentHeaderView: View {
|
||||
}()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
if carriedMailCount > 0 {
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
.headerTapTarget()
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
|
||||
locale: .current,
|
||||
carriedMailCount
|
||||
)
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
|
||||
)
|
||||
}
|
||||
|
||||
if appChromeModel.hasUnreadPrivateMessages {
|
||||
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
@@ -183,6 +213,13 @@ struct ContentHeaderView: View {
|
||||
headerOtherPeersCount
|
||||
)
|
||||
)
|
||||
// Connected-vs-nobody is otherwise encoded only in the icon's
|
||||
// color; say it.
|
||||
.accessibilityValue(
|
||||
headerPeersReachable
|
||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
@@ -190,8 +227,16 @@ struct ContentHeaderView: View {
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
// Fixed height is load-bearing: children fill the bar with
|
||||
// .frame(maxHeight: .infinity) tap targets, so an open-ended
|
||||
// minHeight lets the header expand to swallow the whole screen.
|
||||
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
|
||||
// Type.
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||
carriedMailCount = count
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||
.environmentObject(locationChannelsModel)
|
||||
@@ -266,14 +311,25 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
/// state the count icon's color encodes visually.
|
||||
var headerPeersReachable: Bool {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
return peerListModel.visibleGeohashPeerCount > 0
|
||||
case .mesh:
|
||||
return peerListModel.connectedMeshPeerCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
let count = peerListModel.visibleGeohashPeerCount
|
||||
return (count, count > 0 ? palette.locationAccent : Color.secondary)
|
||||
return (count, count > 0 ? palette.locationAccent : palette.secondary)
|
||||
case .mesh:
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
|
||||
return (peerListModel.reachableMeshPeerCount, color)
|
||||
}
|
||||
}
|
||||
@@ -293,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
|
||||
Text("content.notes.title")
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
Spacer()
|
||||
Button(action: { showLocationNotes = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
SheetCloseButton { showLocationNotes = false }
|
||||
.foregroundColor(palette.primary)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.themedChromePanel(edge: .top)
|
||||
Text("content.notes.location_unavailable")
|
||||
|
||||
@@ -134,6 +134,7 @@ private struct ContentPeopleListView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var peerListModel: PeerListModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@@ -159,24 +160,23 @@ private struct ContentPeopleListView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// .help maps to the accessibility *hint* on iOS, so the
|
||||
// button still needs a spoken name.
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
dismiss()
|
||||
showSidebar = false
|
||||
showVerifySheet = false
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
let activeText = String.localizedStringWithFormat(
|
||||
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
|
||||
Text(subtitle)
|
||||
.foregroundColor(subtitleColor)
|
||||
Text(activeText)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.bitchatFont(size: 12)
|
||||
} else {
|
||||
Text(activeText)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -231,6 +231,13 @@ private struct ContentPeopleListView: View {
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
appChromeModel.showFingerprint(for: peerID)
|
||||
},
|
||||
onToggleBlock: { peer in
|
||||
if peer.isBlocked {
|
||||
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
|
||||
} else {
|
||||
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -333,6 +340,9 @@ private struct ContentPrivateChatSheetView: View {
|
||||
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
|
||||
// Same visual box + 44pt hit target as SheetCloseButton.
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
privateConversationModel.endConversation()
|
||||
showSidebar = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
// minHeight so scaled text at accessibility sizes grows the
|
||||
// bar instead of clipping inside it.
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.themedSurface()
|
||||
.modifier(PrivateHeaderChrome())
|
||||
}
|
||||
|
||||
MessageListView(
|
||||
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
|
||||
Divider()
|
||||
}
|
||||
|
||||
privacyCaption
|
||||
|
||||
#if os(iOS)
|
||||
ContentComposerView(
|
||||
messageText: $messageText,
|
||||
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Persistent one-line reminder that this composer feeds a private
|
||||
/// conversation — the DM sheet otherwise renders identically to the
|
||||
/// public timeline. Claims end-to-end encryption only once the session
|
||||
/// is actually secured.
|
||||
private var privacyCaption: some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 9))
|
||||
// Optical centering: lock.fill's ink is bottom-heavy, so
|
||||
// geometric centering reads low next to the caption text.
|
||||
.offset(y: -1)
|
||||
Text(verbatim: privacyCaptionText)
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
}
|
||||
.foregroundColor(Color.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
// The orange text is signature enough; a tinted band here reads as a
|
||||
// stray strip against the untinted composer chrome below it, so the
|
||||
// caption sits on the same surface as the rest of the bottom chrome.
|
||||
.themedSurface()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var privacyCaptionText: String {
|
||||
// Geohash DMs are NIP-17 gift-wrapped — 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.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let noiseSecured: Bool = {
|
||||
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||
case .noiseSecured, .noiseVerified: return true
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
}
|
||||
}
|
||||
|
||||
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
|
||||
/// over an opaque themed surface. Glass gets the same floating panel as the
|
||||
/// main header instead: an orange wash over the backdrop gradient reads as a
|
||||
/// muddy gray-beige band, and the DM signature is already carried by the
|
||||
/// orange lock, caption, and composer accents.
|
||||
private struct PrivateHeaderChrome: ViewModifier {
|
||||
@Environment(\.appTheme) private var theme
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if theme.usesGlassChrome {
|
||||
content.themedChromePanel(edge: .top)
|
||||
} else {
|
||||
// Orange tint before themedSurface so it layers in front of the
|
||||
// opaque themed background rather than behind it.
|
||||
content
|
||||
.background(Color.orange.opacity(0.06))
|
||||
.themedSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrivateHeaderInfoButton: View {
|
||||
@@ -452,17 +523,30 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
EmptyView()
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
.bitchatFont(size: 16, weight: .medium)
|
||||
.foregroundColor(palette.primary)
|
||||
// Middle truncation keeps the identity suffix visible on
|
||||
// long nicknames instead of wrapping into the fixed-height
|
||||
// header.
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if let encryptionStatus = headerState.encryptionStatus,
|
||||
let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
// Optical centering: the lock glyphs' ink is bottom-heavy
|
||||
// (solid body, thin shackle), so geometric centering reads
|
||||
// ~1pt low next to the name. The seal badge is symmetric
|
||||
// and needs no lift.
|
||||
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
|
||||
.foregroundColor(
|
||||
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
|
||||
? palette.primary
|
||||
@@ -489,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,10 +54,7 @@ struct FingerprintView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
}
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
@@ -83,7 +80,7 @@ struct FingerprintView: View {
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
// Their fingerprint
|
||||
@@ -101,7 +98,7 @@ struct FingerprintView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
@@ -135,7 +132,7 @@ struct FingerprintView: View {
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
|
||||
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -46,7 +53,12 @@ struct GeohashPeopleList: View {
|
||||
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
||||
let rowColor: Color = person.isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
Image(systemName: icon)
|
||||
// Size 10 to match the mesh rows' leading glyphs —
|
||||
// both lists share the sidebar.
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(rowColor)
|
||||
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
@@ -54,6 +66,8 @@ struct GeohashPeopleList: View {
|
||||
.bitchatFont(size: 14)
|
||||
.fontWeight(person.isMe ? .bold : .regular)
|
||||
.foregroundColor(rowColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: person))
|
||||
.accessibilityAddTraits(person.isMe ? [] : .isButton)
|
||||
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
|
||||
.accessibilityActions {
|
||||
if !person.isMe {
|
||||
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
|
||||
if person.isBlocked {
|
||||
peerListModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
} else {
|
||||
peerListModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One spoken sentence per row: name, presence type, and block state.
|
||||
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
|
||||
var parts: [String] = [person.displayName]
|
||||
if person.isMe { parts.append(Strings.youState) }
|
||||
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
if person.isBlocked { parts.append(Strings.blockedState) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
Group {
|
||||
switch locationChannelsModel.permissionState {
|
||||
@@ -122,7 +125,7 @@ struct LocationChannelsSheet: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.permissionDenied)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
SheetCloseButton { isPresented = false }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
|
||||
) {
|
||||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
|
||||
HStack(spacing: 2) {
|
||||
Text(verbatim: "#")
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
|
||||
.bitchatFont(size: 14)
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
@@ -357,7 +357,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
|
||||
) {
|
||||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||
@@ -399,6 +402,8 @@ struct LocationChannelsSheet: View {
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
accessoryActionTitle: String? = nil,
|
||||
accessoryAction: (() -> Void)? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
@@ -409,17 +414,17 @@ struct LocationChannelsSheet: View {
|
||||
Text(parts.base)
|
||||
.bitchatFont(size: 14)
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
.foregroundColor(titleColor ?? palette.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
Text(subtitleFull)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
@@ -434,6 +439,19 @@ struct LocationChannelsSheet: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
// The row is a plain HStack with a tap gesture, which VoiceOver reads
|
||||
// as disconnected static text. Expose it as one activatable button;
|
||||
// the visible bookmark accessory is mirrored as a named action.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
|
||||
.accessibilityHint(Strings.switchChannelHint)
|
||||
.accessibilityAction(.default, action)
|
||||
.accessibilityActions {
|
||||
if let accessoryActionTitle, let accessoryAction {
|
||||
Button(accessoryActionTitle, action: accessoryAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -477,16 +495,16 @@ extension LocationChannelsSheet {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Strings.torTitle)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(Strings.torSubtitle)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ struct LocationNotesView: View {
|
||||
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||
|
||||
private enum Strings {
|
||||
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
SheetCloseButton { dismiss() }
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
|
||||
}
|
||||
Text(Strings.description)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
|
||||
if !ts.isEmpty {
|
||||
Text(ts)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -197,7 +190,7 @@ struct LocationNotesView: View {
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.relaysRetryHint)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.bitchatFont(size: 12)
|
||||
.buttonStyle(.plain)
|
||||
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNotes)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -222,7 +215,7 @@ struct LocationNotesView: View {
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
Text(Strings.emptySubtitle)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ private typealias PlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
struct BlockRevealImageView: View {
|
||||
@ThemedPalette private var palette
|
||||
private let url: URL
|
||||
private let revealProgress: Double?
|
||||
private let isSending: Bool
|
||||
@@ -20,6 +21,25 @@ struct BlockRevealImageView: View {
|
||||
@State private var platformImage: PlatformImage?
|
||||
@State private var aspectRatio: CGFloat = 1
|
||||
@State private var isBlurred: Bool = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@State private var loadFailed = false
|
||||
|
||||
private enum Strings {
|
||||
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
|
||||
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
|
||||
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
|
||||
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
|
||||
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
|
||||
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
|
||||
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
|
||||
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
|
||||
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
|
||||
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
|
||||
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
|
||||
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
|
||||
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
|
||||
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
}
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
@@ -69,20 +89,32 @@ struct BlockRevealImageView: View {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.black.opacity(0.35))
|
||||
.overlay(
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "eye.slash.fill")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
Text(verbatim: Strings.tapToReveal)
|
||||
// Themed: monospaced under matrix,
|
||||
// system under liquid glass.
|
||||
.bitchatFont(size: 12, weight: .medium)
|
||||
}
|
||||
.foregroundColor(.white.opacity(0.85))
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.fill(palette.secondary.opacity(0.2))
|
||||
.frame(height: 200)
|
||||
.overlay(
|
||||
.overlay {
|
||||
if loadFailed {
|
||||
Image(systemName: "photo")
|
||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||
.foregroundColor(palette.secondary)
|
||||
} else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
|
||||
.padding(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.cancelSend)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
|
||||
loadImage()
|
||||
}
|
||||
.gesture(mainGesture)
|
||||
.contextMenu {
|
||||
if isSending {
|
||||
cancelSendAction
|
||||
} else {
|
||||
imageActions
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
Strings.deleteConfirmTitle,
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button(Strings.delete, role: .destructive) {
|
||||
onDelete?()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(verbatim: Strings.deleteConfirmMessage)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityLabelText)
|
||||
.accessibilityHint(accessibilityHintText)
|
||||
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
|
||||
.accessibilityActions {
|
||||
if isSending {
|
||||
// children: .ignore collapses the visible cancel button, so
|
||||
// expose it as an action while the send is in flight.
|
||||
cancelSendAction
|
||||
} else {
|
||||
imageActions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var cancelSendAction: some View {
|
||||
if let onCancel {
|
||||
Button(Strings.cancelSend, action: onCancel)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var imageActions: some View {
|
||||
// Open/reveal/hide would act on a file that failed to load, so only
|
||||
// offer delete (when available) to let users clean up the attachment.
|
||||
if !loadFailed {
|
||||
if isBlurred {
|
||||
Button(Strings.reveal) {
|
||||
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
|
||||
}
|
||||
} else {
|
||||
Button(Strings.open) { onOpen?() }
|
||||
Button(Strings.hide) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
if onDelete != nil {
|
||||
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var accessibilityLabelText: String {
|
||||
if isSending { return Strings.sendingImage }
|
||||
if loadFailed { return Strings.unavailableImage }
|
||||
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
|
||||
}
|
||||
|
||||
private var accessibilityHintText: String {
|
||||
if isSending || loadFailed { return "" }
|
||||
return isBlurred ? Strings.revealHint : Strings.openHint
|
||||
}
|
||||
|
||||
private func loadImage() {
|
||||
loadFailed = false
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
||||
let image = UIImage(contentsOfFile: url.path)
|
||||
#else
|
||||
guard let image = NSImage(contentsOf: url) else { return }
|
||||
let image = NSImage(contentsOf: url)
|
||||
#endif
|
||||
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||
DispatchQueue.main.async {
|
||||
guard let image else {
|
||||
self.loadFailed = true
|
||||
return
|
||||
}
|
||||
self.platformImage = image
|
||||
self.aspectRatio = ratio
|
||||
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Double-tap used to permanently delete the image — the most ingrained
|
||||
// photo gesture on mobile, racing the reveal tap, with no confirmation
|
||||
// and no way to get the file back. Delete now lives in the context menu
|
||||
// behind a confirmation; taps only reveal and open.
|
||||
private var mainGesture: some Gesture {
|
||||
let doubleTap = TapGesture(count: 2).onEnded {
|
||||
guard !isSending else { return }
|
||||
onDelete?()
|
||||
}
|
||||
let singleTap = TapGesture().onEnded {
|
||||
guard !isSending else { return }
|
||||
guard !isSending, !loadFailed else { return }
|
||||
if isBlurred {
|
||||
withAnimation(.easeOut(duration: 0.2)) {
|
||||
isBlurred = false
|
||||
@@ -139,7 +247,7 @@ struct BlockRevealImageView: View {
|
||||
}
|
||||
}
|
||||
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
|
||||
guard !isSending else { return }
|
||||
guard !isSending, !loadFailed else { return }
|
||||
let horizontal = value.translation.width
|
||||
let vertical = value.translation.height
|
||||
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
|
||||
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
||||
return singleTap.simultaneously(with: swipe)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import BitFoundation
|
||||
struct MediaMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
let message: BitchatMessage
|
||||
let media: BitchatMessage.Media
|
||||
@@ -20,6 +21,7 @@ struct MediaMessageView: View {
|
||||
/// fields by identity, so without the snapshot a status-only change
|
||||
/// (send progress, delivered → read) would not re-render this row.
|
||||
private let deliveryStatus: DeliveryStatus?
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
@Binding var imagePreviewURL: URL?
|
||||
|
||||
@@ -35,15 +37,55 @@ struct MediaMessageView: View {
|
||||
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
|
||||
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
|
||||
|
||||
// Baseline alignment (via the header text inside the VStack) keeps the
|
||||
// lock on the header line; a fixed top padding left its solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.orange.opacity(0.75))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .center, spacing: 4) {
|
||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
// Delivery status indicator for private messages. Tappable:
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +116,14 @@ struct MediaMessageView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
// Collapse the revealed caption when the status advances (e.g.
|
||||
// sending → sent → delivered) so a detail opened for one state
|
||||
// doesn't linger and silently morph into another.
|
||||
.onChange(of: deliveryStatus) { _ in
|
||||
showDeliveryDetail = false
|
||||
}
|
||||
}
|
||||
|
||||
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
|
||||
@@ -90,7 +139,7 @@ struct MediaMessageView: View {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
case .sent, .carried, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
|
||||
}
|
||||
|
||||
private var backgroundColor: Color {
|
||||
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
||||
// Palette-based and slightly translucent so the card doesn't sit as
|
||||
// an opaque white/black box over the glass gradient.
|
||||
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
@@ -50,6 +52,12 @@ struct VoiceNoteView: View {
|
||||
.background(Circle().fill(palette.accent))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
playback.isPlaying
|
||||
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
|
||||
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
|
||||
)
|
||||
.accessibilityValue(playbackLabel)
|
||||
|
||||
WaveformView(
|
||||
samples: samples,
|
||||
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
|
||||
|
||||
Text(playbackLabel)
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(Color.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
if let onCancel = onCancel, isSending {
|
||||
Button(action: onCancel) {
|
||||
@@ -74,6 +82,9 @@ struct VoiceNoteView: View {
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
|
||||
@@ -42,7 +42,7 @@ struct WaveformView: View {
|
||||
} else if let send = clampedSend, binPosition <= send {
|
||||
color = palette.accentBlue
|
||||
} else {
|
||||
color = Color.gray.opacity(0.35)
|
||||
color = palette.secondary.opacity(0.35)
|
||||
}
|
||||
context.fill(Path(rect), with: .color(color))
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ struct MeshPeerList: View {
|
||||
let onTapPeer: (PeerID) -> Void
|
||||
let onToggleFavorite: (PeerID) -> Void
|
||||
let onShowFingerprint: (PeerID) -> Void
|
||||
/// Optional so existing call sites (and previews/tests) keep compiling;
|
||||
/// when absent the block/unblock context-menu entry is hidden.
|
||||
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
@State private var orderedIDs: [String] = []
|
||||
@@ -15,6 +18,20 @@ struct MeshPeerList: View {
|
||||
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
|
||||
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
|
||||
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
|
||||
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
|
||||
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
|
||||
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
|
||||
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
|
||||
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
|
||||
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
|
||||
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -49,21 +66,25 @@ struct MeshPeerList: View {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
.help(Strings.connected)
|
||||
} else if peer.isReachable {
|
||||
// Mesh-reachable (relayed): point.3 icon
|
||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(baseColor)
|
||||
.help(Strings.reachable)
|
||||
} else if peer.isMutualFavorite {
|
||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||
Image(systemName: "globe")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(.purple)
|
||||
.help(Strings.nostr)
|
||||
} else {
|
||||
// Fallback icon for others (dimmed)
|
||||
Image(systemName: "person")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(palette.secondary)
|
||||
.help(Strings.offline)
|
||||
}
|
||||
|
||||
let (base, suffix) = peer.displayName.splitSuffix()
|
||||
@@ -71,6 +92,8 @@ struct MeshPeerList: View {
|
||||
Text(base)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(baseColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
if !suffix.isEmpty {
|
||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||
Text(suffix)
|
||||
@@ -91,6 +114,10 @@ struct MeshPeerList: View {
|
||||
if let icon = peer.encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
// Optical centering: lock glyph ink is
|
||||
// bottom-heavy, so geometric centering
|
||||
// reads low next to the name.
|
||||
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
} else {
|
||||
@@ -103,6 +130,7 @@ struct MeshPeerList: View {
|
||||
// Fallback to whatever status says (likely lock if we had a past session)
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
|
||||
.foregroundColor(baseColor)
|
||||
}
|
||||
}
|
||||
@@ -123,6 +151,11 @@ struct MeshPeerList: View {
|
||||
Image(systemName: peer.isFavorite ? "star.fill" : "star")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
|
||||
// Widen the tap target beyond the bare glyph;
|
||||
// height stays row-bound so neighboring rows
|
||||
// keep their own taps.
|
||||
.frame(width: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -131,8 +164,53 @@ struct MeshPeerList: View {
|
||||
.padding(.vertical, 4)
|
||||
.padding(.top, idx == 0 ? 10 : 0)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
// count:2 must attach before count:1 or the single tap
|
||||
// shadows it (same ordering the header logo relies on).
|
||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||
.contextMenu {
|
||||
if !isMe {
|
||||
Button(Strings.directMessage) {
|
||||
onTapPeer(peer.peerID)
|
||||
}
|
||||
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||
onToggleFavorite(peer.peerID)
|
||||
}
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleBlock {
|
||||
if peer.isBlocked {
|
||||
Button(Strings.unblock) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
} else {
|
||||
Button(Strings.block, role: .destructive) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: peer))
|
||||
.accessibilityAddTraits(isMe ? [] : .isButton)
|
||||
.accessibilityHint(isMe ? "" : Strings.openDMHint)
|
||||
.accessibilityActions {
|
||||
if !isMe {
|
||||
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||
onToggleFavorite(peer.peerID)
|
||||
}
|
||||
Button(Strings.showFingerprint) {
|
||||
onShowFingerprint(peer.peerID)
|
||||
}
|
||||
if let onToggleBlock {
|
||||
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
|
||||
onToggleBlock(peer)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One spoken sentence per row: name, how they're reachable, and any
|
||||
/// state badges — the visual row is icon soup for VoiceOver otherwise.
|
||||
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
|
||||
var parts: [String] = [peer.displayName]
|
||||
if !peer.isMe {
|
||||
if peer.isConnected {
|
||||
parts.append(Strings.connected)
|
||||
} else if peer.isReachable {
|
||||
parts.append(Strings.reachable)
|
||||
} else if peer.isMutualFavorite {
|
||||
parts.append(Strings.nostr)
|
||||
} else {
|
||||
parts.append(Strings.offline)
|
||||
}
|
||||
}
|
||||
if peer.isFavorite { parts.append(Strings.favorite) }
|
||||
if peer.hasUnread { parts.append(Strings.unread) }
|
||||
if peer.isBlocked { parts.append(Strings.blocked) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,17 @@ struct MessageListView: View {
|
||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
||||
|
||||
@State private var showMessageActions = false
|
||||
@State private var showClearConfirmation = false
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
@State private var unseenCount = 0
|
||||
@State private var lastSeenMessageCount = 0
|
||||
/// Context key the unseen counters were baselined against. Channel
|
||||
/// switches swap the timeline wholesale, so a count delta is only a
|
||||
/// "new messages" signal while the context is unchanged.
|
||||
@State private var unseenBaselineKey = ""
|
||||
|
||||
@ThemedPalette private var palette
|
||||
|
||||
var body: some View {
|
||||
let currentWindowCount: Int = {
|
||||
@@ -65,6 +74,9 @@ struct MessageListView: View {
|
||||
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
if messageItems.isEmpty && privatePeer == nil {
|
||||
publicEmptyState
|
||||
}
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(messageItems) { item in
|
||||
let message = item.message
|
||||
@@ -72,6 +84,7 @@ struct MessageListView: View {
|
||||
.onAppear {
|
||||
if message.id == windowedMessages.last?.id {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
if message.id == windowedMessages.first?.id,
|
||||
messages.count > windowedMessages.count {
|
||||
@@ -89,13 +102,32 @@ struct MessageListView: View {
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if message.sender != "system" {
|
||||
messageText = "@\(message.sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
|
||||
if showsUserActions {
|
||||
// Mention and DM are redundant inside a 1:1 conversation:
|
||||
// mentioning the only other participant is noise, and "DM"
|
||||
// would just reopen the conversation that is already open.
|
||||
if privatePeer == nil {
|
||||
Button("content.actions.mention") {
|
||||
insertMention(message.sender)
|
||||
}
|
||||
if let peerID = message.senderPeerID {
|
||||
Button("content.actions.direct_message") {
|
||||
privateConversationModel.openConversation(for: peerID)
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("content.actions.hug") {
|
||||
conversationUIModel.sendHug(to: message.sender)
|
||||
}
|
||||
Button("content.actions.slap") {
|
||||
conversationUIModel.sendSlap(to: message.sender)
|
||||
}
|
||||
}
|
||||
Button("content.message.copy") {
|
||||
#if os(iOS)
|
||||
UIPasteboard.general.string = message.content
|
||||
@@ -105,6 +137,16 @@ struct MessageListView: View {
|
||||
pb.setString(message.content, forType: .string)
|
||||
#endif
|
||||
}
|
||||
if isResendableFailedMessage(message) {
|
||||
Button("content.actions.resend") {
|
||||
conversationUIModel.resendFailedPrivateMessage(message)
|
||||
}
|
||||
}
|
||||
if showsUserActions {
|
||||
Button("content.actions.block", role: .destructive) {
|
||||
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 1)
|
||||
@@ -113,10 +155,25 @@ struct MessageListView: View {
|
||||
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if !isAtBottom && !messageItems.isEmpty {
|
||||
jumpToLatestPill(proxy: proxy)
|
||||
}
|
||||
}
|
||||
.onOpenURL(perform: handleOpenURL)
|
||||
.onTapGesture(count: 3) {
|
||||
showClearConfirmation = true
|
||||
}
|
||||
.confirmationDialog(
|
||||
"content.clear.confirm_title",
|
||||
isPresented: $showClearConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("content.clear.confirm_action", role: .destructive) {
|
||||
conversationUIModel.clearCurrentConversation()
|
||||
}
|
||||
Button("common.cancel", role: .cancel) {}
|
||||
}
|
||||
.onAppear {
|
||||
scrollToBottom(on: proxy)
|
||||
}
|
||||
@@ -139,9 +196,7 @@ struct MessageListView: View {
|
||||
) {
|
||||
Button("content.actions.mention") {
|
||||
if let sender = selectedMessageSender {
|
||||
// Pre-fill the input with an @mention and focus the field
|
||||
messageText = "@\(sender) "
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
insertMention(sender)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,6 +263,142 @@ struct MessageListView: View {
|
||||
}
|
||||
|
||||
private extension MessageListView {
|
||||
var currentContextKey: String {
|
||||
if let peer = privatePeer {
|
||||
return "dm:\(peer)"
|
||||
}
|
||||
return locationChannelsModel.selectedChannel.contextKey
|
||||
}
|
||||
|
||||
/// Terminal-styled narration for an empty public timeline: says which
|
||||
/// channel this is, that the app is waiting for peers, and where to go
|
||||
/// next. Rendered inside the ScrollView; disappears with the first row.
|
||||
var publicEmptyState: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
|
||||
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
case .location(let channel):
|
||||
emptyStateLine(
|
||||
String(
|
||||
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
)
|
||||
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
func emptyStateLine(_ text: String) -> some View {
|
||||
// Non-breaking space before the closing asterisk so a tight wrap
|
||||
// can't orphan a lone "*" onto its own line.
|
||||
Text(verbatim: "* \(text)\u{00A0}*")
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(palette.secondary.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
/// Messages the unseen counters may book as "new": rows that render as
|
||||
/// human messages. System lines render as narration and whitespace-only
|
||||
/// content never renders at all, so neither belongs in the pill count.
|
||||
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
|
||||
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
|
||||
}
|
||||
|
||||
/// Updates the unseen-count baseline for the current context and returns
|
||||
/// how many messages were appended since the last observation. A context
|
||||
/// change (timeline swapped wholesale) re-baselines and reports zero, so
|
||||
/// cross-channel count differences are never booked as "new" messages.
|
||||
func rebaselinedAppendedCount(newCount: Int) -> Int {
|
||||
let key = currentContextKey
|
||||
if unseenBaselineKey != key {
|
||||
unseenBaselineKey = key
|
||||
unseenCount = 0
|
||||
lastSeenMessageCount = newCount
|
||||
return 0
|
||||
}
|
||||
let appended = max(0, newCount - lastSeenMessageCount)
|
||||
lastSeenMessageCount = newCount
|
||||
return appended
|
||||
}
|
||||
|
||||
/// A failed private text message of our own can be resent through the
|
||||
/// normal send path (the context menu removes the failed original and
|
||||
/// re-submits its content).
|
||||
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
|
||||
guard message.isPrivate,
|
||||
conversationUIModel.isSentByCurrentUser(message),
|
||||
conversationUIModel.mediaAttachment(for: message) == nil,
|
||||
case .some(.failed) = message.deliveryStatus
|
||||
else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
/// Appends an @mention to the composer draft (never overwrites what the
|
||||
/// user has already typed) and focuses the input field.
|
||||
func insertMention(_ sender: String) {
|
||||
let mention = "@\(sender) "
|
||||
if messageText.isEmpty {
|
||||
messageText = mention
|
||||
} else if messageText.hasSuffix(" ") {
|
||||
messageText += mention
|
||||
} else {
|
||||
messageText += " " + mention
|
||||
}
|
||||
isTextFieldFocused.wrappedValue = true
|
||||
}
|
||||
|
||||
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
|
||||
/// unseenCount state the view already tracks, and jumps to the newest
|
||||
/// message via the existing scrollToBottom helper.
|
||||
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
|
||||
Button {
|
||||
scrollToBottom(on: proxy)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.bitchatSystem(size: 11, weight: .semibold))
|
||||
if unseenCount > 0 {
|
||||
Text(
|
||||
String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
)
|
||||
.bitchatFont(size: 12, weight: .medium)
|
||||
}
|
||||
}
|
||||
.foregroundColor(palette.primary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.themedOverlayPanel()
|
||||
.padding(.trailing, 12)
|
||||
.padding(.bottom, 10)
|
||||
.accessibilityLabel(jumpToLatestAccessibilityLabel)
|
||||
}
|
||||
|
||||
var jumpToLatestAccessibilityLabel: String {
|
||||
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
|
||||
guard unseenCount > 0 else { return base }
|
||||
let count = String(
|
||||
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
|
||||
locale: .current,
|
||||
unseenCount
|
||||
)
|
||||
return "\(base), \(count)"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func messageRow(for message: BitchatMessage) -> some View {
|
||||
Group {
|
||||
@@ -294,6 +485,9 @@ private extension MessageListView {
|
||||
|
||||
func scrollToBottom(on proxy: ScrollViewProxy) {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
|
||||
unseenBaselineKey = currentContextKey
|
||||
if let targetPeerID {
|
||||
proxy.scrollTo(targetPeerID, anchor: .bottom)
|
||||
}
|
||||
@@ -316,15 +510,23 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||
guard privatePeer == nil else { return }
|
||||
let messages = publicChatModel.messages
|
||||
guard privatePeer == nil, let lastMsg = messages.last else { return }
|
||||
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||
guard let lastMsg = messages.last else {
|
||||
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||
unseenCount = 0
|
||||
return
|
||||
}
|
||||
|
||||
// If the newest message is from me, always scroll to bottom
|
||||
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
unseenCount += appendedCount
|
||||
return
|
||||
} else { // Ensure we consider ourselves at bottom for subsequent messages
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
@@ -352,18 +554,23 @@ private extension MessageListView {
|
||||
}
|
||||
|
||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||
guard let peerID = privatePeer,
|
||||
let lastMsg = privateInboxModel.messages(for: peerID).last else {
|
||||
guard let peerID = privatePeer else { return }
|
||||
let messages = privateInboxModel.messages(for: peerID)
|
||||
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||
guard let lastMsg = messages.last else {
|
||||
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||
unseenCount = 0
|
||||
return
|
||||
}
|
||||
let messages = privateInboxModel.messages(for: peerID)
|
||||
|
||||
// If the newest private message is from me, always scroll
|
||||
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
|
||||
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
|
||||
unseenCount += appendedCount
|
||||
return
|
||||
} else {
|
||||
isAtBottom = true
|
||||
unseenCount = 0
|
||||
}
|
||||
|
||||
func scrollIfNeeded(date: Date) {
|
||||
@@ -391,19 +598,29 @@ private extension MessageListView {
|
||||
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
|
||||
// When switching to a new geohash channel, scroll to the bottom
|
||||
guard privatePeer == nil else { return }
|
||||
switch channel {
|
||||
case .mesh:
|
||||
break
|
||||
case .location(let ch):
|
||||
// Reset window size
|
||||
// Invalidate the unseen baseline: the timeline is about to swap (or
|
||||
// already has — the ordering of this onChange vs the count onChange
|
||||
// is not guaranteed), so the next count observation re-baselines
|
||||
// instead of booking the cross-channel difference as "new".
|
||||
unseenCount = 0
|
||||
unseenBaselineKey = ""
|
||||
// Entering any public channel shows its latest messages: a channel
|
||||
// switch swaps the timeline wholesale, so the prior scroll offset is
|
||||
// meaningless. Landing at the bottom keeps isAtBottom honest (no
|
||||
// stale jump-to-latest pill) and matches standard chat behavior.
|
||||
isAtBottom = true
|
||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
||||
let contextKey = "geo:\(ch.geohash)"
|
||||
let contextKey: String
|
||||
switch channel {
|
||||
case .mesh:
|
||||
contextKey = "mesh"
|
||||
case .location(let ch):
|
||||
contextKey = "geo:\(ch.geohash)"
|
||||
}
|
||||
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||
proxy.scrollTo(target, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func conversationMessages(for privatePeer: PeerID?) -> [BitchatMessage] {
|
||||
if let privatePeer {
|
||||
@@ -426,6 +643,6 @@ private extension ChannelID {
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// #Preview {
|
||||
// MessageListView()
|
||||
//}
|
||||
// }
|
||||
|
||||
@@ -11,7 +11,10 @@ import AppKit
|
||||
struct MyQRView: View {
|
||||
let qrString: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
@ThemedPalette private var palette
|
||||
// Palette-tinted so the box follows the theme (green under matrix)
|
||||
// instead of a fixed gray band over the glass gradient.
|
||||
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||
|
||||
private enum Strings {
|
||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||
@@ -50,6 +53,7 @@ struct MyQRView: View {
|
||||
struct QRCodeImage: View {
|
||||
let data: String
|
||||
let size: CGFloat
|
||||
@ThemedPalette private var palette
|
||||
|
||||
private let context = CIContext()
|
||||
private let filter = CIFilter.qrCodeGenerator()
|
||||
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
|
||||
.frame(width: size, height: size)
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
|
||||
.frame(width: size, height: size)
|
||||
.overlay(
|
||||
Text(Strings.unavailable)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.gray)
|
||||
.foregroundColor(palette.secondary)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +112,7 @@ struct ImageWrapper: View {
|
||||
/// Placeholder scanner UI; real camera scanning will be added later.
|
||||
struct QRScanView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@ThemedPalette private var palette
|
||||
var isActive: Bool = true
|
||||
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||
@State private var input = ""
|
||||
@@ -153,7 +158,7 @@ struct QRScanView: View {
|
||||
.bitchatFont(size: 14, weight: .medium)
|
||||
TextEditor(text: $input)
|
||||
.frame(height: 100)
|
||||
.border(Color.gray.opacity(0.4))
|
||||
.border(palette.secondary.opacity(0.4))
|
||||
Button(Strings.validate) {
|
||||
// Deduplicate: ignore if we just processed this exact QR
|
||||
guard input != lastValid else {
|
||||
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
|
||||
}
|
||||
|
||||
final class PreviewView: UIView {
|
||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
||||
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -285,7 +290,7 @@ struct VerificationSheetView: View {
|
||||
|
||||
private var backgroundColor: Color { palette.background }
|
||||
private var accentColor: Color { palette.accent }
|
||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
|
||||
.bitchatFont(size: 14, weight: .bold)
|
||||
.foregroundColor(accentColor)
|
||||
Spacer()
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
showingScanner = false
|
||||
isPresented = false
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundColor(accentColor)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
|
||||
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
|
||||
self.loadFirstPlainText(from: providers) { text in
|
||||
if let t = text, !t.isEmpty {
|
||||
// Treat as URL if parseable http(s), else plain text
|
||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
|
||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
||||
} else {
|
||||
self.saveAndFinish(text: t)
|
||||
|
||||
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
|
||||
context.meshNicknamesByPeerID = [
|
||||
PeerID(str: "1111111111111111"): "alice",
|
||||
PeerID(str: "2222222222222222"): "bob",
|
||||
PeerID(str: "3333333333333333"): "me",
|
||||
PeerID(str: "3333333333333333"): "me"
|
||||
]
|
||||
|
||||
// Matching query: suggestions and range are published, index resets.
|
||||
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
|
||||
"aaaabbbbccccdddd": "carol",
|
||||
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
|
||||
// identity's public key hex ends in "2222".
|
||||
"ffffeeeeddddcccc2222": "me",
|
||||
"ffffeeeeddddcccc2222": "me"
|
||||
]
|
||||
|
||||
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
|
||||
|
||||
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
// Same message under both keys: the read copy must win over sent.
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
|
||||
makePrivateMessage(id: "m2", timestamp: t2),
|
||||
makePrivateMessage(id: "m2", timestamp: t2)
|
||||
]
|
||||
context.privateChats[stablePeerID] = [
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
|
||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
|
||||
]
|
||||
|
||||
let merged = coordinator.getPrivateChatMessages(for: peerID)
|
||||
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
makePrivateMessage(id: "m1", senderPeerID: convKey),
|
||||
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
|
||||
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
|
||||
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
|
||||
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
|
||||
]
|
||||
|
||||
coordinator.markPrivateMessagesAsRead(from: convKey)
|
||||
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
||||
)
|
||||
context.privateChats[peerID] = [
|
||||
makePrivateMessage(id: "in-1", senderPeerID: peerID),
|
||||
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
|
||||
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
|
||||
]
|
||||
|
||||
coordinator.markPrivateMessagesAsRead(from: peerID)
|
||||
|
||||
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
|
||||
peerID: currentPeer,
|
||||
noisePublicKey: Data(repeating: 0x01, count: 32),
|
||||
nickname: "alice"
|
||||
),
|
||||
)
|
||||
]
|
||||
context.unreadPrivateMessages = [
|
||||
currentPeer,
|
||||
staleShortPeer,
|
||||
geoDMWithMessages,
|
||||
geoDMWithoutMessages,
|
||||
noiseKeyWithMessages,
|
||||
noiseKeyWithMessages
|
||||
]
|
||||
context.privateChats = [
|
||||
geoDMWithMessages: [makeMessage(id: "geo-1")],
|
||||
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
|
||||
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
|
||||
]
|
||||
|
||||
coordinator.didUpdatePeerList([currentPeer])
|
||||
|
||||
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
||||
context.privateChats[convKey] = [
|
||||
makeIncomingMessage(id: "mine-1", sender: "me"),
|
||||
makeIncomingMessage(id: "mine-2", sender: "me"),
|
||||
makeIncomingMessage(id: "mine-2", sender: "me")
|
||||
]
|
||||
|
||||
coordinator.handleDelivered(
|
||||
|
||||
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
context.privateChats[peerID] = [
|
||||
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
||||
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
|
||||
]
|
||||
coordinator.didDisconnectFromPeer(peerID)
|
||||
await drainMainActorTasks()
|
||||
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
context.unreadPrivateMessages = [peerID]
|
||||
context.privateChats[peerID] = [
|
||||
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
|
||||
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
||||
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
|
||||
]
|
||||
|
||||
coordinator.didDisconnectFromPeer(peerID)
|
||||
|
||||
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
// failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
case .carried: #expect(index == 3)
|
||||
case .partiallyDelivered: #expect(index == 4)
|
||||
case .delivered: #expect(index == 5)
|
||||
case .read: #expect(index == 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
let didAppend = await TestHelpers.waitUntil({
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
||||
})
|
||||
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
|
||||
// LocationChannelManager is a process-wide singleton: a suite
|
||||
// running in parallel (e.g. CommandProcessorTests) can flip the
|
||||
// selected channel mid-test, which reroutes or drops the event
|
||||
// permanently — no amount of waiting recovers it. Re-assert the
|
||||
// channel and redeliver on each poll: every channel switch clears
|
||||
// the processed-event set and the store dedups by message ID, so
|
||||
// redelivery is idempotent and interference heals on the next
|
||||
// poll while a genuine failure still times out.
|
||||
if LocationChannelManager.shared.selectedChannel != channel {
|
||||
LocationChannelManager.shared.select(channel)
|
||||
}
|
||||
if viewModel.activeChannel == channel {
|
||||
viewModel.handleNostrEvent(signed)
|
||||
}
|
||||
return false
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didAppend)
|
||||
}
|
||||
|
||||
@@ -1000,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendVoiceNote(at: url)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
||||
// 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)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
|
||||
@@ -1020,7 +1039,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didFail = await TestHelpers.waitUntil({
|
||||
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
|
||||
}, timeout: 5.0)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didFail)
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
@@ -1036,7 +1055,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
viewModel.sendImage(from: sourceURL)
|
||||
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||
#expect(didSend)
|
||||
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
|
||||
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||
@@ -1057,7 +1076,7 @@ struct ChatViewModelMediaTransferTests {
|
||||
|
||||
let didNotify = await TestHelpers.waitUntil({
|
||||
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
|
||||
}, timeout: 5.0)
|
||||
}, timeout: TestConstants.longTimeout)
|
||||
#expect(didNotify)
|
||||
#expect(transport.sentPrivateFiles.isEmpty)
|
||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||
|
||||
@@ -263,6 +263,59 @@ struct ChatViewModelCommandTests {
|
||||
#expect(transport.sentPrivateMessages.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_outputRoutesToOpenPrivateChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleCommand("/help")
|
||||
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
|
||||
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_errorRoutesToOpenPrivateChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
viewModel.selectedPrivateChatPeer = peerID
|
||||
|
||||
viewModel.handleCommand("/bogus")
|
||||
|
||||
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
|
||||
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
|
||||
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
|
||||
viewModel.handleCommand("/bogus")
|
||||
|
||||
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "0000000000000002")
|
||||
transport.simulateConnect(peerID, nickname: "Alice")
|
||||
let resolved = await TestHelpers.waitUntil({
|
||||
viewModel.getPeerIDForNickname("Alice") == peerID
|
||||
}, timeout: TestConstants.defaultTimeout)
|
||||
#expect(resolved)
|
||||
|
||||
viewModel.handleCommand("/msg Alice")
|
||||
|
||||
#expect(viewModel.selectedPrivateChatPeer == peerID)
|
||||
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
|
||||
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Composer Tests
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//
|
||||
// CourierStoreTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct CourierStoreTests {
|
||||
|
||||
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
|
||||
private func makeStore(now: Date = baseDate) -> CourierStore {
|
||||
CourierStore(persistsToDisk: false, now: { now })
|
||||
}
|
||||
|
||||
/// Store whose clock can be advanced by tests.
|
||||
private final class Clock {
|
||||
var now: Date
|
||||
init(_ now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private func makeEnvelope(
|
||||
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
||||
sealedAt: Date = baseDate,
|
||||
lifetime: TimeInterval = 60 * 60,
|
||||
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
||||
) -> CourierEnvelope {
|
||||
CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
||||
),
|
||||
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
}
|
||||
|
||||
private let depositorA = Data(repeating: 0xA1, count: 32)
|
||||
private let depositorB = Data(repeating: 0xA2, count: 32)
|
||||
|
||||
// MARK: - Deposit and handover
|
||||
|
||||
@Test func depositThenTakeForRecipient() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
let taken = store.takeEnvelopes(for: recipientKey)
|
||||
#expect(taken == [envelope])
|
||||
// Handover removes the envelope.
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func takeIgnoresOtherRecipients() {
|
||||
let store = makeStore()
|
||||
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
||||
store.deposit(envelope, from: depositorA)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
||||
}
|
||||
|
||||
@Test func duplicateDepositIsIdempotent() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Validity
|
||||
|
||||
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
||||
let store = makeStore()
|
||||
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
||||
#expect(!store.deposit(expired, from: depositorA))
|
||||
|
||||
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
||||
#expect(!store.deposit(oversized, from: depositorA))
|
||||
|
||||
let badTag = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0, count: 4),
|
||||
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: Data(repeating: 1, count: 16)
|
||||
)
|
||||
#expect(!store.deposit(badTag, from: depositorA))
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
||||
let store = makeStore()
|
||||
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
||||
#expect(!store.deposit(pinned, from: depositorA))
|
||||
}
|
||||
|
||||
// MARK: - Quotas
|
||||
|
||||
@Test func perDepositorQuota() {
|
||||
let store = makeStore()
|
||||
for _ in 0..<CourierStore.Limits.maxPerDepositor {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||
}
|
||||
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
||||
// A different depositor still has room.
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
||||
}
|
||||
|
||||
@Test func totalQuotaEvictsOldestFirst() {
|
||||
let store = makeStore()
|
||||
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
||||
let first = makeEnvelope(recipientKey: firstRecipient)
|
||||
store.deposit(first, from: depositorA)
|
||||
|
||||
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
||||
var deposited = 1
|
||||
var depositorByte: UInt8 = 1
|
||||
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
let depositor = Data(repeating: depositorByte, count: 32)
|
||||
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositor))
|
||||
deposited += 1
|
||||
}
|
||||
depositorByte += 1
|
||||
}
|
||||
|
||||
// The first envelope was evicted to make room.
|
||||
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Expiry over time
|
||||
|
||||
@Test func expiredEnvelopesAreNotHandedOver() {
|
||||
let clock = Clock(Self.baseDate)
|
||||
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
||||
|
||||
clock.now = Self.baseDate.addingTimeInterval(7200)
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Panic wipe
|
||||
|
||||
@Test func wipeDropsEverything() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
||||
store.wipe()
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
@Test func persistsAndReloadsAcrossInstances() throws {
|
||||
// Isolated on-disk location so the test never touches the real store.
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
.appendingPathComponent("envelopes.json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
||||
|
||||
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||
|
||||
let recipientKey = Data(repeating: 0xE0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(first.deposit(envelope, from: depositorA))
|
||||
|
||||
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
//
|
||||
// CourierEndToEndTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Three-node courier flow exercised through real BLEService instances with
|
||||
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
|
||||
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
|
||||
/// opens it and sees Alice's message in the right DM thread.
|
||||
struct CourierEndToEndTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private final class PacketTap {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock(); packets.append(packet); lock.unlock()
|
||||
}
|
||||
|
||||
func first(ofType type: MessageType) -> BitchatPacket? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.first { $0.type == type.rawValue }
|
||||
}
|
||||
|
||||
func count(ofType type: MessageType) -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func all(ofType type: MessageType) -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
|
||||
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return payloads
|
||||
}
|
||||
|
||||
// Unused BitchatDelegate requirements.
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||
}
|
||||
|
||||
private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = identityManager ?? MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
service.courierStore = CourierStore(persistsToDisk: false)
|
||||
return service
|
||||
}
|
||||
|
||||
/// Handling any packet from a peer preseeds it as a connected,
|
||||
/// verified entry in the receiving service's registry.
|
||||
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("ping".utf8),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
// Alice and Carol are mutual favorites; trust policy is exercised
|
||||
// separately in depositFromUntrustedPeerIsRejected.
|
||||
carol.courierDepositPolicy = { _ in true }
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
// Alice can see Carol; Bob is nowhere on the mesh.
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// 1. Alice seals to Bob's static key and deposits with Carol.
|
||||
#expect(alice.sendCourierMessage(
|
||||
"the camp moved north",
|
||||
messageID: "courier-msg-1",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
// 3. Later, Bob announces near Carol → handover fires.
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
||||
|
||||
// 4. Ferry the handover to Bob; he opens the envelope.
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
#expect(delivered.type == .privateMessage)
|
||||
// Sender resolves to Alice's stable mesh identity, not the courier's.
|
||||
#expect(delivered.peerID == alice.myPeerID)
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.messageID == "courier-msg-1")
|
||||
#expect(message.content == "the camp moved north")
|
||||
}
|
||||
|
||||
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bobIdentity = MockIdentityManager(MockKeychain())
|
||||
let bob = makeService(identityManager: bobIdentity)
|
||||
carol.courierDepositPolicy = { _ in true }
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// Bob blocked Alice by her stable Noise identity while she was away.
|
||||
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"you should not see this",
|
||||
messageID: "courier-msg-blocked-sender",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
|
||||
// Bob opens the envelope — but the sealed sender is blocked, and it
|
||||
// must never reach the UI. The live block check can't cover this: the
|
||||
// sender is absent from Bob's registry, so no fingerprint resolves at
|
||||
// delivery time.
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!delivered)
|
||||
}
|
||||
|
||||
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
carol.courierDepositPolicy = { _ in true }
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"hold until verified",
|
||||
messageID: "courier-msg-unverified-announce",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
|
||||
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!leakedOnUnverifiedAnnounce)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
}
|
||||
|
||||
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
carol.courierDepositPolicy = { _ in true }
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"hold for a direct encounter",
|
||||
messageID: "courier-msg-relayed-announce",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
|
||||
// A relayed copy has a decremented TTL but a still-valid signature
|
||||
// (TTL is excluded from announce signatures). Envelopes are removed
|
||||
// from the store optimistically, so handover must wait for a direct
|
||||
// encounter instead of chasing a multi-hop path.
|
||||
var relayedAnnounce = directAnnounce
|
||||
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
||||
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!leakedOnRelayedAnnounce)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
|
||||
// The relayed copy consumed the original announce's dedup key
|
||||
// (sender/timestamp/payload — TTL excluded), so the direct handover
|
||||
// needs a fresh announce. Wait out the 1s announce throttle first.
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let freshAnnounce = try #require(
|
||||
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
||||
)
|
||||
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
|
||||
#expect(!alice.sendCourierMessage(
|
||||
"this cannot be sealed",
|
||||
messageID: "courier-msg-invalid-key",
|
||||
recipientNoiseKey: Data(repeating: 0x01, count: 8),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
|
||||
let queuedPacket = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!queuedPacket)
|
||||
}
|
||||
|
||||
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
||||
let carol = makeService()
|
||||
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
|
||||
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
|
||||
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||
let now = Date()
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: bobKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: sealed
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
|
||||
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let mallory = makeService()
|
||||
preseedConnectedPeer(alice, in: carol)
|
||||
preseedConnectedPeer(mallory, in: carol)
|
||||
|
||||
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
|
||||
carol.courierDepositPolicy = { depositorKey in
|
||||
depositorKey == trustedAliceKey
|
||||
}
|
||||
|
||||
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
|
||||
let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||
let now = Date()
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: bobKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: sealed
|
||||
)
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
|
||||
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Unsigned",
|
||||
noisePublicKey: service.noiseStaticPublicKeyData(),
|
||||
signingPublicKey: service.noiseSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Router courier selection
|
||||
|
||||
/// Minimal transport stub for exercising MessageRouter's courier deposit
|
||||
/// logic without BLE plumbing.
|
||||
private final class CourierCaptureTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var eventDelegate: TransportEventDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var snapshots: [TransportPeerSnapshot] = []
|
||||
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
|
||||
private(set) var directSends: [String] = []
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just(snapshots).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
|
||||
|
||||
var myPeerID = PeerID(str: "00000000000000aa")
|
||||
var myNickname = "stub"
|
||||
func setNickname(_ nickname: String) {}
|
||||
|
||||
func startServices() {}
|
||||
func stopServices() {}
|
||||
func emergencyDisconnectAll() {}
|
||||
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
||||
}
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String]) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
directSends.append(messageID)
|
||||
}
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||
func sendBroadcastAnnounce() {}
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
|
||||
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
courierSends.append((messageID, recipientNoiseKey, couriers))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageRouterCourierTests {
|
||||
|
||||
@Test @MainActor
|
||||
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
let daveKey = Data(repeating: 0xD0, count: 32)
|
||||
let daveID = PeerID(publicKey: daveKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
// Carol: connected mutual favorite → eligible courier.
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
|
||||
// Dave: connected but not trusted → never a courier.
|
||||
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
|
||||
]
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
|
||||
|
||||
#expect(transport.directSends.isEmpty)
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m1")
|
||||
#expect(transport.courierSends.first?.recipientKey == bobKey)
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m1"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func noCourierDepositWithoutKnownRecipientKey() {
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
|
||||
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func reachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
|
||||
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
|
||||
|
||||
#expect(transport.directSends == ["m3"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +174,7 @@ struct IntegrationTests {
|
||||
}
|
||||
|
||||
// Encrypted path: use NoiseSessionManager explicitly
|
||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
||||
let plaintext = Data("Encrypted message".utf8)
|
||||
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
||||
|
||||
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
|
||||
@@ -206,7 +206,7 @@ struct IntegrationTests {
|
||||
|
||||
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
|
||||
// David tracks received messages
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { message in
|
||||
helper.nodes["David"]!.messageDeliveryHandler = { _ in
|
||||
completion()
|
||||
}
|
||||
|
||||
@@ -288,7 +288,7 @@ struct IntegrationTests {
|
||||
}
|
||||
|
||||
do {
|
||||
let plaintext = "After restart success".data(using: .utf8)!
|
||||
let plaintext = Data("After restart success".utf8)
|
||||
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
||||
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
|
||||
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
|
||||
|
||||
@@ -121,4 +121,3 @@ final class TestNetworkHelper {
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
|
||||
Data(),
|
||||
Data(repeating: 0x00, count: 32),
|
||||
Data([0x01] + Array(repeating: 0x00, count: 31)),
|
||||
Data(repeating: 0xFF, count: 32),
|
||||
Data(repeating: 0xFF, count: 32)
|
||||
]
|
||||
|
||||
for invalidKey in invalidKeys {
|
||||
|
||||
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
|
||||
@Test func basicEncryptionDecryption() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Hello, Bob!".data(using: .utf8)!
|
||||
let plaintext = Data("Hello, Bob!".utf8)
|
||||
|
||||
// Alice encrypts
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
// Alice -> Bob
|
||||
let aliceMessage = "Hello from Alice".data(using: .utf8)!
|
||||
let aliceMessage = Data("Hello from Alice".utf8)
|
||||
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
|
||||
let bobReceived = try bobSession.decrypt(aliceCiphertext)
|
||||
#expect(bobReceived == aliceMessage)
|
||||
|
||||
// Bob -> Alice
|
||||
let bobMessage = "Hello from Bob".data(using: .utf8)!
|
||||
let bobMessage = Data("Hello from Bob".utf8)
|
||||
let bobCiphertext = try bobSession.encrypt(bobMessage)
|
||||
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
|
||||
#expect(aliceReceived == bobMessage)
|
||||
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
|
||||
}
|
||||
|
||||
@Test func encryptionBeforeHandshake() {
|
||||
let plaintext = "test".data(using: .utf8)!
|
||||
let plaintext = Data("test".utf8)
|
||||
|
||||
#expect(throws: NoiseSessionError.notEstablished) {
|
||||
try aliceSession.encrypt(plaintext)
|
||||
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Encrypt with manager
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let plaintext = Data("Test message".utf8)
|
||||
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
|
||||
// Decrypt with manager
|
||||
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
|
||||
@Test func tamperedCiphertextDetection() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let plaintext = Data("Secret message".utf8)
|
||||
var ciphertext = try aliceSession.encrypt(plaintext)
|
||||
|
||||
// Tamper with ciphertext
|
||||
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
|
||||
@Test func replayPrevention() throws {
|
||||
try performHandshake(initiator: aliceSession, responder: bobSession)
|
||||
|
||||
let plaintext = "Test message".data(using: .utf8)!
|
||||
let plaintext = Data("Test message".utf8)
|
||||
let ciphertext = try aliceSession.encrypt(plaintext)
|
||||
|
||||
// First decryption should succeed
|
||||
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
|
||||
try performHandshake(initiator: aliceSession2, responder: bobSession2)
|
||||
|
||||
// Encrypt with session 1
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let ciphertext1 = try aliceSession1.encrypt(plaintext)
|
||||
|
||||
// Should not be able to decrypt with session 2
|
||||
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Exchange some messages to establish nonce state
|
||||
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
|
||||
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
|
||||
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
||||
|
||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
|
||||
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
|
||||
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
||||
|
||||
// Simulate Bob restart by creating new manager with same key
|
||||
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = "After restart".data(using: .utf8)!
|
||||
let testMessage = Data("After restart".utf8)
|
||||
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
|
||||
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Exchange messages to advance nonces
|
||||
for i in 0..<5 {
|
||||
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
|
||||
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
|
||||
_ = try bobSession.decrypt(msg)
|
||||
}
|
||||
|
||||
// Simulate desynchronization by encrypting but not decrypting
|
||||
for i in 0..<3 {
|
||||
_ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
|
||||
_ = try aliceSession.encrypt(Data("Lost message \(i)".utf8))
|
||||
}
|
||||
|
||||
// With per-packet nonce carried, decryption should not throw here
|
||||
let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
|
||||
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
|
||||
#expect(throws: Never.self) {
|
||||
try bobSession.decrypt(desyncMessage)
|
||||
}
|
||||
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
|
||||
|
||||
let messageCount = 100
|
||||
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
|
||||
{ completion in
|
||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||
var encryptedMessages: [Int: Data] = [:]
|
||||
// Encrypt messages sequentially to avoid nonce races in manager
|
||||
for i in 0..<messageCount {
|
||||
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let plaintext = Data("Concurrent message \(i)".utf8)
|
||||
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||
encryptedMessages[i] = encrypted
|
||||
}
|
||||
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
|
||||
return
|
||||
}
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
||||
let expected = Data("Concurrent message \(i)".utf8)
|
||||
#expect(decrypted == expected)
|
||||
completion()
|
||||
} catch {
|
||||
@@ -485,7 +484,7 @@ struct NoiseProtocolTests {
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
// Create a corrupted message
|
||||
var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
|
||||
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
|
||||
encrypted[10] ^= 0xFF // Corrupt the data
|
||||
|
||||
// Decryption should fail
|
||||
@@ -516,7 +515,7 @@ struct NoiseProtocolTests {
|
||||
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
|
||||
|
||||
// Exchange messages to verify sessions work
|
||||
let testMessage = "Session works".data(using: .utf8)!
|
||||
let testMessage = Data("Session works".utf8)
|
||||
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
|
||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||
#expect(decrypted == testMessage)
|
||||
@@ -539,7 +538,7 @@ struct NoiseProtocolTests {
|
||||
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
|
||||
|
||||
// Verify new sessions work
|
||||
let testMessage2 = "New session works".data(using: .utf8)!
|
||||
let testMessage2 = Data("New session works".utf8)
|
||||
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
|
||||
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
|
||||
#expect(decrypted2 == testMessage2)
|
||||
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
// Exchange messages normally
|
||||
for i in 0..<5 {
|
||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
|
||||
_ = try bobManager.decrypt(msg, from: bobPeerID)
|
||||
}
|
||||
|
||||
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
|
||||
for i in 0..<3 {
|
||||
_ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
|
||||
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
|
||||
}
|
||||
|
||||
// With nonce carried in packet, decryption should not throw here
|
||||
let desyncMessage = try aliceManager.encrypt(
|
||||
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
||||
Data("This now succeeds".utf8), for: alicePeerID)
|
||||
#expect(throws: Never.self) {
|
||||
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||
}
|
||||
@@ -587,7 +586,7 @@ struct NoiseProtocolTests {
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = "Resynced".data(using: .utf8)!
|
||||
let testResynced = Data("Resynced".utf8)
|
||||
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
|
||||
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
|
||||
#expect(decryptedResync == testResynced)
|
||||
@@ -900,20 +899,20 @@ struct NoiseProtocolTests {
|
||||
#expect(trackingKeychain.secureClearDataCallCount > 0)
|
||||
|
||||
// Test encryption from Alice to Bob
|
||||
let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
|
||||
let plaintext1 = Data("Hello from Alice after secureClear!".utf8)
|
||||
let ciphertext1 = try alice.encrypt(plaintext1)
|
||||
let decrypted1 = try bob.decrypt(ciphertext1)
|
||||
#expect(decrypted1 == plaintext1)
|
||||
|
||||
// Test encryption from Bob to Alice
|
||||
let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
|
||||
let plaintext2 = Data("Hello from Bob after secureClear!".utf8)
|
||||
let ciphertext2 = try bob.encrypt(plaintext2)
|
||||
let decrypted2 = try alice.decrypt(ciphertext2)
|
||||
#expect(decrypted2 == plaintext2)
|
||||
|
||||
// Test multiple messages to verify cipher state is correct
|
||||
for i in 1...10 {
|
||||
let msg = "Message \(i) from Alice".data(using: .utf8)!
|
||||
let msg = Data("Message \(i) from Alice".utf8)
|
||||
let cipher = try alice.encrypt(msg)
|
||||
let dec = try bob.decrypt(cipher)
|
||||
#expect(dec == msg)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// NoiseCourierTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// One-way Noise X envelopes: encryption to a known static key without an
|
||||
/// interactive handshake, used by the courier store-and-forward path.
|
||||
struct NoiseCourierTests {
|
||||
|
||||
@Test func sealAndOpenRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let payload = Data("meet at the north gate".utf8)
|
||||
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let opened = try bob.openCourierPayload(sealed)
|
||||
#expect(opened.payload == payload)
|
||||
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||
}
|
||||
|
||||
@Test func wrongRecipientCannotOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try carol.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tamperedEnvelopeFailsToOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func senderIdentityCannotBeForged() throws {
|
||||
// The encrypted static key inside the envelope is bound by the ss DH;
|
||||
// splicing one envelope's ephemeral prefix onto another must fail.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
|
||||
// e (32 bytes) from Mallory's envelope + rest from Alice's.
|
||||
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(Data(spliced))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sealRejectsInvalidRecipientKey() {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
|
||||
}
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func emptyAndLargePayloadsRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
|
||||
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
|
||||
|
||||
let large = Data((0..<8192).map { UInt8($0 % 251) })
|
||||
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(sealed).payload == large)
|
||||
}
|
||||
|
||||
@Test func envelopesAreNotLinkableAcrossSends() throws {
|
||||
// Fresh ephemeral per seal: same payload to the same recipient must
|
||||
// produce entirely different ciphertexts.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let payload = Data("same message".utf8)
|
||||
|
||||
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
#expect(a != b)
|
||||
#expect(a.prefix(32) != b.prefix(32))
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
|
||||
("checking in from the harbor #bitchat #mesh", nil),
|
||||
("@bob#0042 ping me when you get this", ["bob#0042"]),
|
||||
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
|
||||
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
|
||||
]
|
||||
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
|
||||
(0..<batchSize).map { i in
|
||||
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
"anyone near the station?",
|
||||
"@bob#0042 are you on mesh too?",
|
||||
"check this out https://example.com/p/123 #bitchat",
|
||||
"teleport check, who's local?",
|
||||
"teleport check, who's local?"
|
||||
]
|
||||
return try (0..<count).map { i in
|
||||
try NostrProtocol.createEphemeralGeohashEvent(
|
||||
|
||||
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
|
||||
struct ProtocolContractTests {
|
||||
@Test
|
||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||
#expect(CommandInfo.message.id == "dm")
|
||||
#expect(CommandInfo.message.alias == "/dm")
|
||||
// Aliases must match what CommandProcessor actually accepts —
|
||||
// the suggestion panel is the only command-discovery surface.
|
||||
#expect(CommandInfo.message.id == "msg")
|
||||
#expect(CommandInfo.message.alias == "/msg")
|
||||
#expect(CommandInfo.message.placeholder != nil)
|
||||
#expect(CommandInfo.clear.placeholder == nil)
|
||||
#expect(CommandInfo.favorite.description.isEmpty == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
|
||||
// Favorites are rejected by the processor in geohash contexts, so
|
||||
// they are suggested only in mesh.
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
|
||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
|
||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.peerID == peerID)
|
||||
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||
#expect(result?.isDirectAnnounce == true)
|
||||
#expect(result?.isVerified == true)
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
|
||||
#expect(recorder.barrierCount == 1)
|
||||
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.peerID == peerID)
|
||||
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.verifySignatureCalls.isEmpty)
|
||||
#expect(recorder.barrierCount == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.signatureValid = false
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isDirectAnnounce == false)
|
||||
#expect(result?.isVerified == true)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.first?.isConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
|
||||
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
|
||||
#expect(selection.centralIDs == Set(["c2"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["target-p", "bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: nil,
|
||||
peripheralPeerBindings: [
|
||||
"target-p": target,
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["target-p"]))
|
||||
#expect(selection.centralIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: nil,
|
||||
peripheralPeerBindings: [
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs.isEmpty)
|
||||
#expect(selection.centralIDs == Set(["target-c"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: .central("target-c"),
|
||||
peripheralPeerBindings: [
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs.isEmpty)
|
||||
#expect(selection.centralIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendExcludesAllLinksToIngressPeer() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
|
||||
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
|
||||
|
||||
#expect(service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_persistsByFingerprintAndToggles() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
let peerID = PeerID(str: "00000000000000EE")
|
||||
let fingerprint = "fp-target"
|
||||
transport.peerFingerprints[peerID] = fingerprint
|
||||
|
||||
// Blocking resolves and persists by the peer's fingerprint.
|
||||
let resolved = service.setBlocked(peerID, blocked: true)
|
||||
#expect(resolved == fingerprint)
|
||||
#expect(identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(service.isBlocked(peerID))
|
||||
|
||||
// Unblocking clears it against the same identity.
|
||||
let unresolved = service.setBlocked(peerID, blocked: false)
|
||||
#expect(unresolved == fingerprint)
|
||||
#expect(!identity.isBlocked(fingerprint: fingerprint))
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_unknownIdentityReturnsNil() async {
|
||||
let transport = MockTransport()
|
||||
let identity = TestIdentityManager()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||
|
||||
// No fingerprint resolvable for this peer (offline & unknown).
|
||||
let peerID = PeerID(str: "00000000000000FF")
|
||||
|
||||
#expect(service.setBlocked(peerID, blocked: true) == nil)
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
}
|
||||
|
||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
@@ -12,6 +12,11 @@ import Foundation
|
||||
struct TestConstants {
|
||||
static let defaultTimeout: TimeInterval = 5.0
|
||||
static let shortTimeout: TimeInterval = 1.0
|
||||
/// For positive waits on work that hops through `Task.detached` or
|
||||
/// background queues: those contend with every parallel test worker for
|
||||
/// the global executor, so a loaded CI runner can exceed
|
||||
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
|
||||
/// so passing runs never pay the longer timeout.
|
||||
static let longTimeout: TimeInterval = 10.0
|
||||
|
||||
static let testNickname1 = "Alice"
|
||||
|
||||
@@ -54,13 +54,13 @@ final class TestHelpers {
|
||||
type: UInt8 = 0x01,
|
||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||
recipientID: PeerID? = nil,
|
||||
payload: Data = "test payload".data(using: .utf8)!,
|
||||
payload: Data = Data("test payload".utf8),
|
||||
signature: Data? = nil,
|
||||
ttl: UInt8 = 3
|
||||
) -> BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID.id.data(using: .utf8)!,
|
||||
senderID: Data(senderID.id.utf8),
|
||||
recipientID: recipientID?.id.data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
|
||||
@@ -556,11 +556,19 @@ struct ViewSmokeTests {
|
||||
@Test
|
||||
func voiceAndMediaViews_renderAndWarmCaches() async throws {
|
||||
let audioURL = try makeTemporaryAudioURL()
|
||||
// Probed directly below. Deliberately a separate file from `audioURL`:
|
||||
// `WaveformCache.shared` is process-wide and the mounted
|
||||
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
|
||||
// width concurrently, so asserting an exact bin count for that URL
|
||||
// races with the view's own cache write.
|
||||
let waveformProbeURL = try makeTemporaryAudioURL()
|
||||
let imageURL = try makeTemporaryImageURL()
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
try? FileManager.default.removeItem(at: waveformProbeURL)
|
||||
try? FileManager.default.removeItem(at: imageURL)
|
||||
WaveformCache.shared.purge(url: audioURL)
|
||||
WaveformCache.shared.purge(url: waveformProbeURL)
|
||||
}
|
||||
|
||||
let waveformView = WaveformView(
|
||||
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
|
||||
_ = mount(voiceNoteView)
|
||||
|
||||
let bins = await withCheckedContinuation { continuation in
|
||||
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
|
||||
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
|
||||
continuation.resume(returning: values)
|
||||
}
|
||||
}
|
||||
playback.loadDuration()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
// loadDuration hops through a background queue and back to main; poll
|
||||
// instead of a fixed sleep so a loaded runner can't outlast the wait.
|
||||
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
|
||||
playback.seek(to: 1.25)
|
||||
playback.stop()
|
||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
|
||||
await VoiceRecorder.shared.cancelRecording()
|
||||
|
||||
#expect(bins.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
||||
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
||||
#expect(playback.duration > 0)
|
||||
#expect(playback.progress == 0)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import struct Foundation.Data
|
||||
struct XChaCha20Poly1305CompatTests {
|
||||
|
||||
@Test func sealAndOpenRoundtrip() throws {
|
||||
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
|
||||
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealAndOpenWithAAD() throws {
|
||||
let plaintext = "Secret message".data(using: .utf8)!
|
||||
let plaintext = Data("Secret message".utf8)
|
||||
let key = Data(repeating: 0xAB, count: 32)
|
||||
let nonce = Data(repeating: 0xCD, count: 24)
|
||||
let aad = "additional authenticated data".data(using: .utf8)!
|
||||
let aad = Data("additional authenticated data".utf8)
|
||||
|
||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
|
||||
let decrypted = try XChaCha20Poly1305Compat.open(
|
||||
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
|
||||
let plaintext = "Same plaintext".data(using: .utf8)!
|
||||
let plaintext = Data("Same plaintext".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce1 = Data(repeating: 0x01, count: 24)
|
||||
let nonce2 = Data(repeating: 0x02, count: 24)
|
||||
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let shortKey = Data(repeating: 0x42, count: 16)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let longKey = Data(repeating: 0x42, count: 64)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyKey() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let emptyKey = Data()
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnShortNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let shortNonce = Data(repeating: 0x24, count: 12)
|
||||
|
||||
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnLongNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let longNonce = Data(repeating: 0x24, count: 32)
|
||||
|
||||
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func sealThrowsOnEmptyNonce() {
|
||||
let plaintext = "Test".data(using: .utf8)!
|
||||
let plaintext = Data("Test".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let emptyNonce = Data()
|
||||
|
||||
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func openFailsWithWrongKey() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let correctKey = Data(repeating: 0x42, count: 32)
|
||||
let wrongKey = Data(repeating: 0x43, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
|
||||
}
|
||||
|
||||
@Test func openFailsWithTamperedCiphertext() throws {
|
||||
let plaintext = "Secret".data(using: .utf8)!
|
||||
let plaintext = Data("Secret".utf8)
|
||||
let key = Data(repeating: 0x42, count: 32)
|
||||
let nonce = Data(repeating: 0x24, count: 24)
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ let package = Package(
|
||||
name: "Tor", // Keep name "Tor" for drop-in compatibility
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
.macOS(.v13)
|
||||
],
|
||||
products: [
|
||||
.library(
|
||||
name: "Tor",
|
||||
targets: ["Tor"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "../BitLogger"),
|
||||
.package(path: "../BitLogger")
|
||||
],
|
||||
targets: [
|
||||
// Main Swift target
|
||||
@@ -22,19 +22,19 @@ let package = Package(
|
||||
name: "Tor",
|
||||
dependencies: [
|
||||
"arti",
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "BitLogger", package: "BitLogger")
|
||||
],
|
||||
path: "Sources",
|
||||
exclude: ["C"],
|
||||
sources: [
|
||||
"TorManager.swift",
|
||||
"TorURLSession.swift",
|
||||
"TorNotifications.swift",
|
||||
"TorNotifications.swift"
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedLibrary("resolv"),
|
||||
.linkedLibrary("z"),
|
||||
.linkedLibrary("sqlite3"),
|
||||
.linkedLibrary("sqlite3")
|
||||
]
|
||||
),
|
||||
// Binary framework containing the Rust static library.
|
||||
@@ -42,6 +42,6 @@ let package = Package(
|
||||
.binaryTarget(
|
||||
name: "arti",
|
||||
path: "Frameworks/arti.xcframework"
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "BitFoundation",
|
||||
dependencies: [
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "BitLogger", package: "BitLogger")
|
||||
],
|
||||
path: "Sources"
|
||||
),
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// CourierEnvelope.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
private import CryptoKit
|
||||
|
||||
/// TLV payload for store-and-forward courier envelopes.
|
||||
///
|
||||
/// A courier envelope lets a mutual favorite physically carry an encrypted
|
||||
/// message to a peer who is currently offline. The envelope is opaque to the
|
||||
/// courier: the only routing information is a rotating recipient tag derived
|
||||
/// from the recipient's Noise static public key and the UTC day, so envelopes
|
||||
/// addressed to the same peer on different days do not correlate for
|
||||
/// observers who don't already know that peer's public key.
|
||||
public struct CourierEnvelope: Equatable {
|
||||
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
|
||||
public let recipientTag: Data
|
||||
/// Milliseconds since epoch after which the envelope must be discarded.
|
||||
public let expiry: UInt64
|
||||
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
|
||||
public let ciphertext: Data
|
||||
|
||||
public static let tagLength = 16
|
||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||
public static let maxCiphertextBytes = 16 * 1024
|
||||
/// Matches the outbox retention policy in MessageRouter.
|
||||
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case recipientTag = 0x01
|
||||
case expiry = 0x02
|
||||
case ciphertext = 0x03
|
||||
}
|
||||
|
||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
}
|
||||
|
||||
public var isExpired: Bool {
|
||||
isExpired(at: Date())
|
||||
}
|
||||
|
||||
public func isExpired(at date: Date) -> Bool {
|
||||
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
|
||||
}
|
||||
|
||||
public func encode() -> Data? {
|
||||
guard recipientTag.count == Self.tagLength else { return nil }
|
||||
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
|
||||
|
||||
encoded.append(TLVType.recipientTag.rawValue)
|
||||
appendBE(UInt16(recipientTag.count), into: &encoded)
|
||||
encoded.append(recipientTag)
|
||||
|
||||
encoded.append(TLVType.expiry.rawValue)
|
||||
appendBE(UInt16(8), into: &encoded)
|
||||
appendBE(expiry, into: &encoded)
|
||||
|
||||
encoded.append(TLVType.ciphertext.rawValue)
|
||||
appendBE(UInt16(ciphertext.count), into: &encoded)
|
||||
encoded.append(ciphertext)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
public static func decode(_ data: Data) -> CourierEnvelope? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var recipientTag: Data?
|
||||
var expiry: UInt64?
|
||||
var ciphertext: Data?
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return nil }
|
||||
let value = data[cursor..<data.index(cursor, offsetBy: length)]
|
||||
cursor = data.index(cursor, offsetBy: length)
|
||||
|
||||
switch TLVType(rawValue: typeRaw) {
|
||||
case .recipientTag:
|
||||
guard length == tagLength else { return nil }
|
||||
recipientTag = Data(value)
|
||||
case .expiry:
|
||||
guard length == 8 else { return nil }
|
||||
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .ciphertext:
|
||||
guard length > 0, length <= maxCiphertextBytes else { return nil }
|
||||
ciphertext = Data(value)
|
||||
case nil:
|
||||
// Unknown TLV: skip for forward compatibility.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Recipient Tags
|
||||
|
||||
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
|
||||
|
||||
/// UTC day number used to rotate recipient tags.
|
||||
public static func epochDay(for date: Date) -> UInt32 {
|
||||
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
|
||||
}
|
||||
|
||||
/// Rotating recipient hint for a given day. Computable only by parties
|
||||
/// who already know the recipient's Noise static public key.
|
||||
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
|
||||
var message = tagContext
|
||||
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
|
||||
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
|
||||
return Data(mac).prefix(tagLength)
|
||||
}
|
||||
|
||||
/// Tags to test when checking whether an envelope is addressed to a peer.
|
||||
/// Covers the adjacent days so envelopes sealed near midnight (or across
|
||||
/// modest clock skew) still match while being carried.
|
||||
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
|
||||
let day = epochDay(for: date)
|
||||
return [day == 0 ? 0 : day - 1, day, day + 1].map {
|
||||
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import struct Foundation.Date
|
||||
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case carried // Sealed envelope handed to a courier; best-effort physical delivery
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case read(by: String, at: Date) // Seen by recipient
|
||||
case failed(reason: String)
|
||||
@@ -22,6 +23,8 @@ public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .carried:
|
||||
return "Carried by a friend"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .read(let nickname, _):
|
||||
|
||||
@@ -14,6 +14,7 @@ public enum MessageType: UInt8 {
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
case message = 0x02 // Public chat message
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
// Noise encryption
|
||||
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
|
||||
case .announce: return "announce"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .courierEnvelope: return "courierEnvelope"
|
||||
case .requestSync: return "requestSync"
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
|
||||
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
|
||||
// Verify recipient
|
||||
#expect(decodedPacket.recipientID != nil)
|
||||
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
||||
// TODO: Check if this is intended that the decoding only gets the first 8
|
||||
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates
|
||||
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
|
||||
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
||||
}
|
||||
|
||||
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
|
||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||
func payloadCompression() throws {
|
||||
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
||||
let largePayload = repeatedString.data(using: .utf8)!
|
||||
let largePayload = Data(repeatedString.utf8)
|
||||
|
||||
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
||||
|
||||
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
|
||||
|
||||
@Test("Small payloads should not be compressed")
|
||||
func smallPayloadNoCompression() throws {
|
||||
let smallPayload = "Hi".data(using: .utf8)!
|
||||
let smallPayload = Data("Hi".utf8)
|
||||
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
|
||||
var encodedSizes = Set<Int>()
|
||||
|
||||
for payload in payloads {
|
||||
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
||||
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8))
|
||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||
|
||||
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// CourierEnvelopeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct CourierEnvelopeTests {
|
||||
|
||||
private func makeEnvelope(
|
||||
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
|
||||
expiry: UInt64 = 1_900_000_000_000,
|
||||
ciphertext: Data = Data(repeating: 0x42, count: 128)
|
||||
) -> CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Codec
|
||||
|
||||
@Test func roundTrip() throws {
|
||||
let envelope = makeEnvelope()
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == envelope)
|
||||
}
|
||||
|
||||
@Test func roundTripAtMaxCiphertextSize() throws {
|
||||
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == envelope)
|
||||
}
|
||||
|
||||
@Test func encodeRejectsInvalidFields() {
|
||||
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
|
||||
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
|
||||
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsMissingFields() throws {
|
||||
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
|
||||
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsTruncatedValue() throws {
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeSkipsUnknownTLVs() throws {
|
||||
var encoded = try #require(makeEnvelope().encode())
|
||||
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == makeEnvelope())
|
||||
}
|
||||
|
||||
@Test func decodeOffsetSlice() throws {
|
||||
// Decoder must handle slices with non-zero startIndex.
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
let padded = Data([0xFF, 0xFF]) + encoded
|
||||
let slice = padded.dropFirst(2)
|
||||
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
|
||||
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
|
||||
}
|
||||
|
||||
// MARK: - Expiry
|
||||
|
||||
@Test func expiryComparison() {
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
|
||||
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
|
||||
#expect(makeEnvelope(expiry: 0).isExpired)
|
||||
}
|
||||
|
||||
// MARK: - Recipient Tags
|
||||
|
||||
@Test func tagIsDeterministicPerKeyAndDay() {
|
||||
let key = Data(repeating: 0x11, count: 32)
|
||||
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||
#expect(tag1 == tag2)
|
||||
#expect(tag1.count == CourierEnvelope.tagLength)
|
||||
}
|
||||
|
||||
@Test func tagRotatesAcrossDaysAndKeys() {
|
||||
let key = Data(repeating: 0x11, count: 32)
|
||||
let otherKey = Data(repeating: 0x22, count: 32)
|
||||
let day: UInt32 = 20_000
|
||||
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
|
||||
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
|
||||
}
|
||||
|
||||
@Test func candidateTagsCoverAdjacentDays() {
|
||||
let key = Data(repeating: 0x33, count: 32)
|
||||
let date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
let day = CourierEnvelope.epochDay(for: date)
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
|
||||
#expect(candidates.count == 3)
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
|
||||
}
|
||||
|
||||
@Test func sealedYesterdayMatchesToday() {
|
||||
// An envelope sealed late on day D must still match the recipient on day D+1.
|
||||
let key = Data(repeating: 0x44, count: 32)
|
||||
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
|
||||
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
|
||||
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,13 @@ final class TestHelpers {
|
||||
type: UInt8 = 0x01,
|
||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||
recipientID: PeerID? = nil,
|
||||
payload: Data = "test payload".data(using: .utf8)!,
|
||||
payload: Data = Data("test payload".utf8),
|
||||
signature: Data? = nil,
|
||||
ttl: UInt8 = 3
|
||||
) -> BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID.id.data(using: .utf8)!,
|
||||
senderID: Data(senderID.id.utf8),
|
||||
recipientID: recipientID?.id.data(using: .utf8),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
|
||||
@@ -11,16 +11,34 @@
|
||||
# never runner variance. Raise floors deliberately after intentional
|
||||
# improvements; never tune them to chase noise.
|
||||
#
|
||||
# Retry-on-noise: even generous floors can be dipped under by a saturated
|
||||
# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub
|
||||
# macOS runner). When a benchmark lands below its floor, the gate re-runs the
|
||||
# benchmark suite — appending to the same PERF log — and keeps each
|
||||
# benchmark's BEST observed value across attempts. Runner noise clears on a
|
||||
# retry; a real algorithmic regression stays below floor on every attempt and
|
||||
# still fails. Floors themselves are never lowered by this mechanism.
|
||||
#
|
||||
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
|
||||
#
|
||||
# Environment:
|
||||
# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3)
|
||||
# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor
|
||||
# result (default: swift test --quiet
|
||||
# --filter PerformanceBaselineTests). The
|
||||
# command runs with BITCHAT_PERF_LOG pointed at
|
||||
# the output file so new PERF lines append.
|
||||
#
|
||||
# Skips gracefully (exit 0) when:
|
||||
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
|
||||
# - the output contains no PERF lines (e.g. package-only matrix entries).
|
||||
#
|
||||
# Fails (exit 1) when:
|
||||
# - any benchmark reports throughput below its floor, or
|
||||
# - PERF lines are present but a floored benchmark is missing
|
||||
# (a silently-dropped benchmark must be an explicit floors-file change).
|
||||
# Fails when:
|
||||
# - any benchmark reports throughput below its floor on every attempt
|
||||
# (exit 1), or
|
||||
# - PERF lines are present but a floored benchmark is missing — a
|
||||
# silently-dropped benchmark must be an explicit floors-file change and
|
||||
# is not retried (exit 3).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -31,6 +49,8 @@ fi
|
||||
|
||||
OUTPUT_FILE="$1"
|
||||
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
|
||||
MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}"
|
||||
REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}"
|
||||
|
||||
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
|
||||
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
|
||||
@@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||
# Absolute path so re-measurement appends to the same file regardless of the
|
||||
# working directory the test process runs in.
|
||||
case "$OUTPUT_FILE" in
|
||||
/*) ;;
|
||||
*) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;;
|
||||
esac
|
||||
|
||||
# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs
|
||||
# regression undecided), 3 = floored benchmark missing (not retryable).
|
||||
check_floors() {
|
||||
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f:
|
||||
for line in f:
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
# Keep the last reported value if a benchmark prints twice.
|
||||
measured[m.group(1)] = (float(m.group(2)), m.group(3))
|
||||
# Keep the BEST reported value: measurement retries append to the
|
||||
# same log, and a healthy benchmark only needs to clear its floor
|
||||
# once — a real regression never does.
|
||||
name, value, unit = m.group(1), float(m.group(2)), m.group(3)
|
||||
if name not in measured or value > measured[name][0]:
|
||||
measured[name] = (value, unit)
|
||||
|
||||
failures = []
|
||||
below_floor = []
|
||||
missing = []
|
||||
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
|
||||
for name in sorted(set(floors) | set(measured)):
|
||||
floor = floors.get(name)
|
||||
if name not in measured:
|
||||
failures.append(
|
||||
missing.append(
|
||||
f" MISSING {name}: floored benchmark reported no PERF line "
|
||||
f"(removed/renamed? update perf-floors.json in the same change)")
|
||||
continue
|
||||
@@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)):
|
||||
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
|
||||
print(line)
|
||||
if value < floor:
|
||||
failures.append(
|
||||
below_floor.append(
|
||||
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
|
||||
f"({value / floor * 100:.0f}% of floor)")
|
||||
|
||||
if failures:
|
||||
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
|
||||
print("\n".join(failures))
|
||||
if missing:
|
||||
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:")
|
||||
print("\n".join(missing + below_floor))
|
||||
sys.exit(3)
|
||||
|
||||
if below_floor:
|
||||
print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:")
|
||||
print("\n".join(below_floor))
|
||||
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
|
||||
print("algorithmic regression, not runner noise. If the change is intentional,")
|
||||
print("update bitchatTests/Performance/perf-floors.json deliberately.")
|
||||
@@ -106,3 +146,36 @@ if failures:
|
||||
|
||||
print("perf-floors: all benchmarks at or above their floors.")
|
||||
PYEOF
|
||||
}
|
||||
|
||||
attempt=1
|
||||
while true; do
|
||||
gate_status=0
|
||||
check_floors || gate_status=$?
|
||||
|
||||
case "$gate_status" in
|
||||
0)
|
||||
exit 0
|
||||
;;
|
||||
1)
|
||||
# Below floor: retry to separate runner noise from regression.
|
||||
;;
|
||||
*)
|
||||
# Missing benchmark or parse/setup error: re-measuring can't help.
|
||||
exit "$gate_status"
|
||||
;;
|
||||
esac
|
||||
|
||||
if (( attempt >= MAX_ATTEMPTS )); then
|
||||
echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression."
|
||||
# Word splitting of REMEASURE_CMD is deliberate: it is a command line.
|
||||
if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then
|
||||
echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
Reference in New Issue
Block a user