Compare commits

..
Author SHA1 Message Date
jackandGitHub 41486fd1c9 Merge branch 'main' into chore/perf-privacy-polish 2026-07-01 23:58:27 +02:00
jackandClaude Fable 5 d8527b98fd Add a pragmatic SwiftLint configuration
Baseline .swiftlint.yml for local use (not wired into CI yet):

- file_length: warning at 600, error at 4000 — above the current
  maximum (BLEService.swift, ~3,500 lines), so the codebase passes
  as-is and only future growth trips the error.
- function_body_length: warning 100, error 200.
- Disables the high-noise style rules (line_length, identifier_name,
  cyclomatic_complexity, force_cast/force_try for tests, etc.) so the
  remaining defaults stay actionable; SwiftLint is not installed in
  this environment, so the disabled list is a best-effort starting
  point to be tuned on first real run.
- Excludes vendored Arti and SwiftPM .build output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:32:19 +02:00
jackandClaude Fable 5 5be8b5b37f Track CLAUDE.md and update it to match the current architecture
The previous (untracked) CLAUDE.md described ChatViewModel as a ~170KB
class split into +Nostr/+PrivateChat/+Tor extension files. Reality:
ChatViewModel.swift is ~1,600 lines and delegates to ~25 coordinator/
pipeline types in bitchat/ViewModels/; the Extensions/ files are thin
delegation shims. Document the actual structure: AppRuntime as the
composition root, ConversationStore as the single-writer message
store, the BLE handler/policy collaborators around BLEService, and
BinaryProtocol's move into the BitFoundation local package.

Also start tracking CLAUDE.md (removing its .gitignore entry) so the
guidance stays versioned alongside the code it describes; build/test
command sections are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:59 +02:00
jackandClaude Fable 5 7200b3de2a Add NIP-44 style padding support to Nostr DM encryption
The bespoke "v2" DM envelope (XChaCha20-Poly1305 over the raw UTF-8
rumor JSON) leaks exact plaintext length to relays. Add NIP-44 v2
style payload framing: a 2-byte big-endian length prefix followed by
zero padding to NIP-44's calc_padded_len buckets (32-byte minimum,
power-of-two-derived chunks), carried in a new "v3:" envelope that
reuses the existing key derivation and AEAD.

Compatibility: deployed clients hard-reject any envelope that does not
start with "v2:", and the v2 payload is raw JSON, so padded payloads
cannot be introduced inside v2 either. This lands phase 1 of a
two-phase rollout: decrypt accepts both v2 (unpadded) and v3 (padded),
while encrypt keeps emitting v2, gated by
NostrProtocol.sendPaddedEnvelope (defaults to false with the rollout
plan documented inline). Flip the flag once v3-capable clients are
widely deployed.

Unpad validates that the claimed length's bucket exactly matches the
authenticated payload size, so a tampered or malformed length prefix
fails closed. Tests cover pad/unpad round-trips, the NIP-44 bucket
vectors, v3 round-trip, legacy v2 decrypt, default-envelope pinning,
and tampered length prefixes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:30:00 +02:00
jackandClaude Fable 5 3f6dc7dd36 Speed up hex encode/decode with lookup tables
Data.hexEncodedString() built each byte via String(format: "%02x", _),
paying Foundation format-string parsing per byte on the hot BLE receive
path (PeerID(hexData:) is called several times per received packet).
Replace it with an ASCII lookup table writing into a preallocated
buffer; output is byte-for-byte identical (lowercase hex).

Data(hexString:) similarly allocated a two-character substring and went
through integer radix parsing per byte; replace with direct nibble
lookups over the UTF-8 bytes. Semantics are unchanged except that
sign-prefixed pairs like "+f", which the old radix parser incorrectly
accepted, are now rejected.

Add round-trip, known-vector, and invalid-input tests to
BitFoundationTests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:26:38 +02:00
93 changed files with 1135 additions and 4537 deletions
+2 -34
View File
@@ -12,10 +12,7 @@ jobs:
runs-on: macos-latest runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute # A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue). # default (observed: intermittent app-suite hangs starving the queue).
# The long steps carry tighter individual bounds (5-minute test watchdog, timeout-minutes: 15
# 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: strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -105,14 +102,9 @@ jobs:
# Order-of-magnitude performance regression gate. Floors are deliberately # Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this # generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance. If a metric # catches algorithmic regressions, never runner variance.
# 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 - name: Performance floor gate
if: matrix.name == 'app' if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the # Informational only: surfaces per-file and total line coverage in the
@@ -153,27 +145,3 @@ jobs:
ARCHS=arm64 \ ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build 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
-1
View File
@@ -6,7 +6,6 @@
plans/ plans/
## AI ## AI
CLAUDE.md
AGENTS.md AGENTS.md
.claude/ .claude/
+42 -24
View File
@@ -1,33 +1,51 @@
# Build artifacts and generated sources; keeps local `swiftlint` runs clean # SwiftLint configuration for BitChat.
# (CI checkouts are fresh, so this only matters in a working tree). #
# Intentionally pragmatic: rules that would flood the existing codebase are
# disabled so the lint signal stays actionable; re-enable them individually as
# files get cleaned up. Not wired into CI yet — run locally with `swiftlint`
# from the repo root.
included:
- bitchat
- bitchatTests
- localPackages/BitFoundation/Sources
- localPackages/BitFoundation/Tests
- localPackages/BitLogger/Sources
excluded: excluded:
- .build - .build
- .swiftpm - localPackages/Arti
- .DerivedData - localPackages/BitFoundation/.build
- DerivedData - localPackages/BitLogger/.build
- build
- localPackages/*/.build
disabled_rules: disabled_rules:
# Style/volume rules that currently produce noise across the codebase.
- line_length - line_length
- type_name
- identifier_name - identifier_name
- statement_position - type_name
- 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 - type_body_length
- file_length - cyclomatic_complexity
- function_parameter_count
- large_tuple - large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting - nesting
- todo
- trailing_comma
- opening_brace
- statement_position
- for_where
- redundant_string_enum_value
- non_optional_string_data_conversion
# Common in tests (force casts/tries on fixtures).
- force_cast
- force_try
file_length:
warning: 600
# BLEService.swift is currently ~3,500 lines; the error threshold sits above
# today's maximum so the codebase passes as-is and only future growth of the
# largest files trips it.
error: 4000
function_body_length:
warning: 100
error: 200
+106
View File
@@ -0,0 +1,106 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Test Commands
```bash
# Build macOS (no signing)
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build
# Build iOS for simulator
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' build
# Run all tests (iOS simulator)
xcodebuild -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' test
# Run tests via Swift Package Manager (used in CI)
swift build && swift test --parallel
# Clean build
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" clean
# Quick dev build and run (macOS only, requires `just`)
just run
```
### Running Specific Tests
```bash
# Run a single test class
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/IntegrationTests
# Run a single test method
xcodebuild test -project bitchat.xcodeproj -scheme bitchat -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 15' -only-testing:bitchatTests/NoiseProtocolTests/testHandshake
```
## Architecture Overview
BitChat is a **dual-transport P2P messaging app**: Bluetooth mesh for offline local communication, Nostr protocol for internet-based global messaging.
### Local Packages (`localPackages/`)
- **BitFoundation**: Shared foundation types — `BinaryProtocol` (compact binary packet format for BLE), `BitchatPacket`, `BitchatMessage`, `PeerID`, hex/SHA256/compression utilities. Has its own test suite under `localPackages/BitFoundation/Tests` (run `swift test` inside the package).
- **BitLogger**: `SecureLogger` logging.
- **Arti**: Tor integration (exposes the `Tor` product).
### Transport Layer
- **BLEService** (`bitchat/Services/BLE/BLEService.swift`): Core Bluetooth LE mesh networking - peer discovery, connection management, multi-hop relay (max 7 hops), packet fragmentation. The BLE directory contains ~40 focused collaborators (handlers, policies, buffers): `BLEReceivePipeline` dispatches inbound packets to `BLEAnnounceHandler` / `BLENoisePacketHandler` / `BLEPublicMessageHandler` / `BLEFragmentHandler` etc.; outbound planning lives in the `BLEOutbound*` types; peer state in `BLEPeerRegistry`.
- **NostrTransport** (`bitchat/Services/NostrTransport.swift`): Internet transport via Nostr relays with NIP-17 encryption
- **Transport protocol** (`bitchat/Services/Transport.swift`): Common interface both transports implement
### Encryption Layer
- **NoiseProtocol** (`bitchat/Noise/`): Noise XX pattern for end-to-end encryption with forward secrecy
- `NoiseEncryptionService`: Main encryption/decryption API
- `NoiseSessionManager`: Thread-safe per-peer session management
- `NoiseSession`: Individual peer session state (handshake, send/receive ciphers)
- **NostrProtocol** (`bitchat/Nostr/NostrProtocol.swift`): NIP-17 gift-wrapped encryption for Nostr messages
### Protocol Layer
- **BinaryProtocol** (`localPackages/BitFoundation/Sources/BitFoundation/BinaryProtocol.swift`): Compact binary packet format for BLE (lives in BitFoundation, not `bitchat/Protocols/`)
- **BitchatProtocol** (`bitchat/Protocols/BitchatProtocol.swift`): Message types and packet structures
- **LocationChannel/Geohash** (`bitchat/Protocols/`): Geographic channel routing
### Application Layer
- **AppRuntime** (`bitchat/App/AppRuntime.swift`): Composition root. Owns `ChatViewModel`, `ConversationStore`, the feature models (`PublicChatModel`, `PeerListModel`, `LocationPresenceStore`, `PeerIdentityStore`, ...), and the app event stream.
- **ConversationStore** (`bitchat/App/ConversationStore.swift`): Single-writer, single source of truth for conversation message state and selection (see `docs/CONVERSATION-STORE-DESIGN.md`). Feature models and `ChatViewModel` observe it and mutate it through its intent API.
- **ChatViewModel** (`bitchat/ViewModels/ChatViewModel.swift`, ~1,600 lines): Central coordinator that wires and delegates to ~25 focused coordinator/pipeline types in `bitchat/ViewModels/`, e.g.:
- `ChatPrivateConversationCoordinator` / `ChatPublicConversationCoordinator`: DM and public chat flows
- `ChatNostrCoordinator``GeohashSubscriptionManager`, `NostrInboundPipeline`, `GeoPresenceTracker`, `GeoChannelCoordinator`: Nostr/geohash channel logic
- `ChatOutgoingCoordinator`, `ChatDeliveryCoordinator`, `PublicMessagePipeline`: send paths and delivery tracking
- `ChatMediaTransferCoordinator`, `ChatMediaPreparation`: media transfers
- `ChatLifecycleCoordinator`, `ChatTransportEventCoordinator`, `ChatPeerListCoordinator`, `ChatPeerIdentityCoordinator`, `ChatVerificationCoordinator`: lifecycle, transport events, peer state
- `bitchat/ViewModels/Extensions/` (`ChatViewModel+Nostr/+PrivateChat/+Tor`): thin delegation shims kept for call-site stability; the real logic lives in the coordinators
- **MessageRouter** (`bitchat/Services/MessageRouter.swift`): Intelligent transport selection (BLE → Nostr fallback)
- **PrivateChatManager** (`bitchat/Services/PrivateChatManager.swift`): DM session management
## Test Infrastructure
Tests use an **in-memory networking harness** for deterministic, race-free testing:
- **MockBLEService** (`bitchatTests/Mocks/MockBLEService.swift`): Simulated BLE mesh with configurable topology
- `MockBLEService.resetTestBus()` - Clear state in setUp()
- `simulateConnectedPeer(_:)` / `simulateDisconnectedPeer(_:)` - Configure topology
- `autoFloodEnabled` - Enable broadcast flooding for Integration tests only
- **Test categories**:
- `bitchatTests/EndToEnd/`: Full message flow tests with explicit routing
- `bitchatTests/Integration/`: Multi-node topology tests with auto-flooding
- Unit tests: Individual component tests
## Key Patterns
- **Threading**: Use `@MainActor` for UI, `Task { @MainActor in ... }` for main thread dispatch
- **Delegation**: `BitchatDelegate`, `TransportPeerEventsDelegate` for event propagation
- **Session recovery**: On decrypt failure, clear local session and re-initiate Noise handshake (no NACK)
## Device Setup
To run on physical devices:
1. Copy `Configs/Local.xcconfig.example` to `Configs/Local.xcconfig`
2. Add your Developer Team ID to `Local.xcconfig`
3. Replace `group.chat.bitchat` with `group.<your_bundle_id>` in entitlements
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable( .executable(
name: "bitchat", name: "bitchat",
targets: ["bitchat"] targets: ["bitchat"]
) ),
], ],
dependencies: [ dependencies:[
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"), .package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
-20
View File
@@ -49,14 +49,6 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message) 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() { func clearCurrentConversation() {
chatViewModel.sendMessage("/clear") chatViewModel.sendMessage("/clear")
} }
@@ -75,23 +67,11 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat, if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) { let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName) 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 { } else {
chatViewModel.sendMessage("/block \(displayName)") 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) { func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition) chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
} }
+1 -7
View File
@@ -232,13 +232,7 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys let availability = resolveAvailability(for: headerPeerID, peer: peer)
// 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 let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil ? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID) : chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate { final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime? 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 true
} }
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
} }
enum TrustLevel: String, Codable { enum TrustLevel: String, Codable {
case unknown case unknown = "unknown"
case casual case casual = "casual"
case trusted case trusted = "trusted"
case verified case verified = "verified"
} }
// MARK: - Identity Cache // MARK: - Identity Cache
File diff suppressed because it is too large Load Diff
+12 -19
View File
@@ -11,38 +11,33 @@ import Foundation
// MARK: - CommandInfo Enum // MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable { 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 block
case clear case clear
case help
case hug case hug
case message = "msg" case message = "dm"
case slap case slap
case unblock case unblock
case who case who
case favorite = "fav" case favorite
case unfavorite = "unfav" case unfavorite
var id: String { rawValue } var id: String { rawValue }
var alias: String { "/" + rawValue } var alias: String { "/" + rawValue }
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .help, .who: case .clear, .who:
return nil return nil
} }
} }
var description: String { var description: String {
switch self { switch self {
case .block: String(localized: "content.commands.block") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear") case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message") case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap") case .slap: String(localized: "content.commands.slap")
@@ -52,14 +47,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who] let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
if isGeoPublic || isGeoDM { if isGeoPublic || isGeoDM {
return baseCommands return baseCommands + [.favorite, .unfavorite]
} }
return baseCommands + [.favorite, .unfavorite] return baseCommands
} }
} }
+1 -7
View File
@@ -93,7 +93,6 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key case IK // Initiator knows responder's static key
case NK // Anonymous initiator case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
} }
enum NoiseRole { enum NoiseRole {
@@ -602,7 +601,7 @@ final class NoiseHandshakeState {
switch pattern { switch pattern {
case .XX: case .XX:
break // No pre-message keys break // No pre-message keys
case .IK, .NK, .X: case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic { } else if role == .responder, let localStatic = localStaticPublic {
@@ -905,7 +904,6 @@ extension NoisePattern {
case .XX: return "XX" case .XX: return "XX"
case .IK: return "IK" case .IK: return "IK"
case .NK: return "NK" case .NK: return "NK"
case .X: return "X"
} }
} }
@@ -927,10 +925,6 @@ extension NoisePattern {
[.e, .es], // -> e, es [.e, .es], // -> e, es
[.e, .ee] // <- e, ee [.e, .ee] // <- e, ee
] ]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
} }
} }
} }
+118 -26
View File
@@ -328,52 +328,85 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - Encryption (NIP-44 v2) // MARK: - Encryption (NIP-44 v2/v3)
private static func encrypt( /// Whether outgoing DMs use the padded "v3:" envelope.
///
/// TWO-PHASE ROLLOUT DO NOT FLIP YET. Deployed clients hard-reject any
/// ciphertext that does not start with "v2:" (`decrypt` below, as shipped,
/// throws `invalidCiphertext` on unknown version prefixes), and the "v2:"
/// payload is the raw UTF-8 rumor JSON, so a padded payload cannot be
/// smuggled inside "v2:" without breaking old receivers either. Enabling
/// this today would strand every client in the field.
///
/// Phase 1 (this change): ship decrypt-side support for "v3:" everywhere.
/// Phase 2 (future release, once phase-1 clients are widely deployed):
/// set this to true so outgoing DMs stop leaking plaintext length to
/// relays.
static let sendPaddedEnvelope = false
/// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func encrypt(
plaintext: String, plaintext: String,
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey,
padded: Bool = NostrProtocol.sendPaddedEnvelope
) throws -> String { ) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned) // Encrypting message (XChaCha20-Poly1305, versioned envelope)
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info).
// The v3 envelope deliberately reuses the same key derivation; it only
// changes the payload framing (length prefix + padding).
let key = try deriveNIP44V2Key(from: sharedSecret) let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305 // 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in _ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
// v2 payload: raw UTF-8 plaintext (length leaks to relays)
// v3 payload: NIP-44 style [2-byte BE length][plaintext][zero padding]
let pt = Data(plaintext.utf8) let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) let payload = padded ? try NIP44Padding.pad(pt) : pt
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: payload, key: key, nonce24: nonce24)
// v2: base64url(nonce24 || ciphertext || tag)
// version prefix + base64url(nonce24 || ciphertext || tag)
var combined = Data() var combined = Data()
combined.append(nonce24) combined.append(nonce24)
combined.append(sealed.ciphertext) combined.append(sealed.ciphertext)
combined.append(sealed.tag) combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined) return (padded ? "v3:" : "v2:") + Base64URLCoding.encode(combined)
} }
private static func decrypt( /// Internal (rather than private) so tests can exercise both envelope
/// versions directly.
static func decrypt(
ciphertext: String, ciphertext: String,
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format // Accept both the legacy unpadded "v2:" envelope and the padded "v3:"
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } // envelope (see `sendPaddedEnvelope` for the rollout plan).
let isPadded: Bool
if ciphertext.hasPrefix("v2:") {
isPadded = false
} else if ciphertext.hasPrefix("v3:") {
isPadded = true
} else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
@@ -399,18 +432,23 @@ struct NostrProtocol {
} }
// If 32 bytes (x-only) try both parities, otherwise single try // If 32 bytes (x-only) try both parities, otherwise single try
let payload: Data
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? "" payload = pt
} else {
let odd = Data([0x03]) + senderPubkeyData
payload = try attemptDecrypt(using: odd)
} }
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else { } else {
let pt = try attemptDecrypt(using: senderPubkeyData) payload = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
// The AEAD tag has already authenticated the payload; unpadding
// failures here mean a malformed sender, not a wrong key.
let plaintextData = isPadded ? try NIP44Padding.unpad(payload) : payload
return String(data: plaintextData, encoding: .utf8) ?? ""
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -641,6 +679,60 @@ enum NostrError: Error {
case encryptionFailed case encryptionFailed
} }
// MARK: - NIP-44 style padding (v3 envelope payload framing)
/// Payload framing for the padded "v3:" envelope, modeled on NIP-44 v2:
/// `[2-byte big-endian plaintext length][plaintext][zero padding]`, where the
/// total is padded to `paddedLength(for:)` power-of-two-derived buckets with
/// a 32-byte minimum so ciphertext length no longer reveals exact plaintext
/// length to relays.
enum NIP44Padding {
static let minPaddedLength = 32
static let maxPlaintextLength = 65535
/// NIP-44's calc_padded_len: pad to 32 bytes minimum, then to a chunk
/// granularity of max(32, nextPowerOfTwo/8).
static func paddedLength(for unpaddedLength: Int) -> Int {
guard unpaddedLength > minPaddedLength else { return minPaddedLength }
// Smallest power of two strictly greater than (unpaddedLength - 1).
let nextPower = 1 << (Int.bitWidth - (unpaddedLength - 1).leadingZeroBitCount)
let chunk = nextPower <= 256 ? 32 : nextPower / 8
return chunk * ((unpaddedLength - 1) / chunk + 1)
}
/// Prefix plaintext with its 2-byte big-endian length and zero-pad to the
/// bucketed length. Rejects empty plaintexts and plaintexts that do not
/// fit the 16-bit length prefix.
static func pad(_ plaintext: Data) throws -> Data {
let length = plaintext.count
guard length >= 1, length <= maxPlaintextLength else {
throw NostrError.encryptionFailed
}
let padded = paddedLength(for: length)
var result = Data(capacity: 2 + padded)
result.append(UInt8(length >> 8))
result.append(UInt8(length & 0xFF))
result.append(plaintext)
result.append(Data(count: padded - length))
return result
}
/// Read the 2-byte length prefix, validate the total padded size matches
/// it exactly, and return the plaintext. Throws on any inconsistency so a
/// malformed (already-authenticated) payload can never over- or
/// under-read.
static func unpad(_ padded: Data) throws -> Data {
guard padded.count >= 2 else { throw NostrError.invalidCiphertext }
let start = padded.startIndex
let length = Int(padded[start]) << 8 | Int(padded[start + 1])
guard length >= 1,
padded.count == 2 + paddedLength(for: length) else {
throw NostrError.invalidCiphertext
}
return padded.subdata(in: (start + 2)..<(start + 2 + length))
}
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol { private extension NostrProtocol {
@@ -648,7 +740,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
info: Data("nip44-v2".utf8), info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32 outputByteCount: 32
) )
return derivedKey.withUnsafeBytes { Data($0) } return derivedKey.withUnsafeBytes { Data($0) }
@@ -132,3 +132,4 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes) replaceSubrange(offset..<(offset+4), with: bytes)
} }
} }
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5 case .city: return 5
case .province: return 4 case .province: return 4
case .region: return 2 case .region: return 2
} }
} }
var displayName: String { var displayName: String {
+5 -22
View File
@@ -59,15 +59,6 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void 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 /// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification, /// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response. /// gossip tracking, and the reciprocal announce response.
@@ -78,8 +69,7 @@ final class BLEAnnounceHandler {
self.environment = environment self.environment = environment
} }
@discardableResult func handle(_ packet: BitchatPacket, from peerID: PeerID) {
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment let env = environment
let now = env.now() let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate( let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -95,15 +85,15 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement announcement = acceptance.announcement
case .reject(.malformed): case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session) SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return nil return
case .reject(.senderMismatch(let derivedFromKey)): case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return nil return
case .reject(.selfAnnounce): case .reject(.selfAnnounce):
return nil return
case .reject(.stale(let ageSeconds)): case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session) SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return nil return
} }
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
@@ -220,12 +210,5 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6) let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay) env.scheduleAfterglow(delay)
} }
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
} }
} }
+6 -64
View File
@@ -19,35 +19,13 @@ enum BLEFanoutSelector {
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
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( let allowed = collapseDuplicateLinksPerPeer(
rawAllowed, allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings centralPeerBindings: centralPeerBindings
) )
@@ -93,42 +71,6 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs) 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 // Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same // peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler // packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) { switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake: case .noiseEncrypted, .noiseHandshake:
return true return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope: case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
return false return false
} }
} }
+8 -177
View File
@@ -46,20 +46,6 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication // 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator() 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 var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -902,10 +888,6 @@ final class BLEService: NSObject {
} else { } else {
packetToSend = packet packetToSend = packet
} }
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1065,9 +1047,6 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents // Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) { private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return } guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID) sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
} }
@@ -1326,7 +1305,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate { extension BLEService: CBCentralManagerDelegate {
#if os(iOS) #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 restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? [] let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:] let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -2008,7 +1987,7 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
#if os(iOS) #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 restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:] let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2489,142 +2468,7 @@ extension BLEService {
ttl: messageTTL ttl: messageTTL
) )
} }
// 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) // MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T { private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2856,7 +2700,7 @@ extension BLEService {
// Notify delegate of failure // Notify delegate of failure
notifyUI { [weak self] in notifyUI { [weak self] in
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")))) self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: "Encryption failed")))
} }
} }
} }
@@ -3109,15 +2953,13 @@ extension BLEService {
case .fileTransfer: case .fileTransfer:
handleFileTransfer(packet, from: senderID) handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave: case .leave:
handleLeave(packet, from: senderID) handleLeave(packet, from: senderID)
case .none: case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session) SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
} }
if forwardAlongRouteIfNeeded(packet) { if forwardAlongRouteIfNeeded(packet) {
@@ -3174,18 +3016,7 @@ extension BLEService {
} }
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID) 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 /// Builds the announce handler environment. All queue hops stay here so
+1 -18
View File
@@ -105,28 +105,11 @@ final class CommandProcessor {
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default: default:
return .error(message: "unknown command: \(cmd) — type /help for commands") return .error(message: "unknown command: \(cmd)")
} }
} }
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] start a private chat
/who list who's here
/clear clear this chat
/hug @name send a hug
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/help this list
"""
// MARK: - Command Handlers // MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult { private func handleMessage(_ args: String) -> CommandResult {
-219
View File
@@ -1,219 +0,0 @@
//
// 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 -64
View File
@@ -2,33 +2,11 @@ import BitLogger
import BitFoundation import BitFoundation
import Foundation 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.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -36,12 +14,6 @@ final class MessageRouter {
/// stale "sending/sent" state forever. /// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)? 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 // Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage { private struct QueuedMessage {
let content: String let content: String
@@ -59,17 +31,10 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never // 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). // get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8 private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init( init(transports: [Transport], now: @escaping () -> Date = Date.init) {
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
) {
self.transports = transports self.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -86,7 +51,7 @@ final class MessageRouter {
} }
// Handle key updates // Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data, if let newKey = note.userInfo?["peerPublicKey"] as? Data,
note.userInfo?["isKeyUpdate"] is Bool { let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerID(publicKey: newKey) let peerID = PeerID(publicKey: newKey)
Task { @MainActor in Task { @MainActor in
self.flushOutbox(for: peerID) self.flushOutbox(for: peerID)
@@ -129,33 +94,6 @@ final class MessageRouter {
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) 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) 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
}
} }
} }
+2 -45
View File
@@ -369,49 +369,6 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? { func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation 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) /// Clear persistent identity (for panic mode)
func clearPersistentIdentity() { func clearPersistentIdentity() {
@@ -471,7 +428,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data() var out = Data()
// context // context
let context = Data("bitchat-announce-v1".utf8) let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
out.append(UInt8(min(context.count, 255))) out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255)) out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -487,7 +444,7 @@ final class NoiseEncryptionService {
out.append(ed32) out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes // nickname length + bytes
let nickData = Data(nickname.utf8) let nickData = nickname.data(using: .utf8) ?? Data()
out.append(UInt8(min(nickData.count, 255))) out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255)) out.append(nickData.prefix(255))
// timestamp // timestamp
+1 -1
View File
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
func peerNickname(peerID: PeerID) -> String? { nil } func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] } func getPeerNicknames() -> [PeerID : String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil } func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none } func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered: case .read, .delivered:
externalReceipts.insert(message.id) externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent, .carried: case .failed, .partiallyDelivered, .sending, .sent:
break break
} }
} }
-7
View File
@@ -95,12 +95,6 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ 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) // QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -126,7 +120,6 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {} func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(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 sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {} func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {} func cancelTransfer(_ transferId: String) {}
+1 -23
View File
@@ -257,29 +257,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false 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 /// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) { func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else { guard let peer = getPeer(by: peerID) else {
-3
View File
@@ -20,9 +20,6 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5 case .fragment: return 5
case .requestSync: return 6 case .requestSync: return 6
case .fileTransfer: return 7 case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
} }
} }
@@ -27,3 +27,4 @@ struct PeerDisplayNameResolver {
return result return result
} }
} }
@@ -355,10 +355,9 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1 case .failed: return 1
case .sending: return 2 case .sending: return 2
case .sent: return 3 case .sent: return 3
case .carried: return 4 case .partiallyDelivered: return 4
case .partiallyDelivered: return 5 case .delivered: return 5
case .delivered: return 6 case .read: return 6
case .read: return 7
} }
} }
} }
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url) try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
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")) self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
} }
} catch { } catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session) SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self else { return } guard let self else { return }
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")) self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
} }
} }
} }
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
key: Data? key: Data?
) { ) {
guard let context else { return } guard let context else { return }
if key != nil { if let _ = key {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
} }
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
} }
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID { if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if key != nil { if let _ = key {
if let identity = context.currentNostrIdentity() { if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity) context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
} }
+3 -61
View File
@@ -988,48 +988,6 @@ 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 { func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex) publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
} }
@@ -1189,9 +1147,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -1480,7 +1435,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'. /// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash /// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help /// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
@MainActor @MainActor
func handleCommand(_ command: String) { func handleCommand(_ command: String) {
let result = commandProcessor.process(command) let result = commandProcessor.process(command)
@@ -1488,29 +1443,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result { switch result {
case .success(let message): case .success(let message):
if let msg = message { if let msg = message {
addCommandOutput(msg) addSystemMessage(msg)
} }
case .error(let message): case .error(let message):
addCommandOutput(message) addSystemMessage(message)
case .handled: case .handled:
// Command was handled, no message needed // Command was handled, no message needed
break 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 // MARK: - Message Reception
@MainActor @MainActor
@@ -100,28 +100,11 @@ private extension ChatViewModelBootstrapper {
category: .session category: .session
) )
viewModel.conversations.setDeliveryStatus( viewModel.conversations.setDeliveryStatus(
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")), .failed(reason: "Not delivered"),
forMessageID: messageID 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.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel) viewModel.participantTracker.configure(context: viewModel)
+8 -44
View File
@@ -55,26 +55,6 @@ 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 { enum Privacy {
static let title: LocalizedStringKey = "app_info.privacy.title" static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo( static let noTracking = AppInfoFeatureInfo(
@@ -138,8 +118,14 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
SheetCloseButton { dismiss() } Button(action: { dismiss() }) {
.foregroundColor(textColor) Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("app_info.close")
} }
} }
} }
@@ -216,28 +202,6 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Features.mentions) 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 // Privacy
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title) SectionHeader(Strings.Privacy.title)
@@ -14,46 +14,29 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String @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] { private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") else { return [] } guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM) return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
// 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()) command.alias.starts(with: messageText.lowercased())
} }
} }
var body: some View { var body: some View {
// Render nothing when there are no matches: a zero-height view would // Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row // still receive the composer VStack's spacing and push the input row
// off-center. // off-center.
if !filteredCommands.isEmpty { if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in ForEach(filteredCommands) { command in
Button { 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 + " " messageText = command.alias + " "
} label: { } label: {
buttonRow(for: command) buttonRow(for: command)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -9,47 +9,6 @@
import SwiftUI import SwiftUI
import BitFoundation 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 { struct DeliveryStatusView: View {
@ThemedPalette private var palette @ThemedPalette private var palette
let status: DeliveryStatus let status: DeliveryStatus
@@ -60,34 +19,56 @@ struct DeliveryStatusView: View {
private var secondaryTextColor: Color { palette.secondary } private var secondaryTextColor: Color { palette.secondary }
// MARK: - Body 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
)
}
var body: some View { static func read(by nickname: String) -> String {
statusGlyph String(
.help(status.bitchatDescription) format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
.accessibilityElement(children: .ignore) locale: .current,
.accessibilityLabel(status.bitchatDescription) 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
)
}
} }
@ViewBuilder // MARK: - Body
private var statusGlyph: some View {
var body: some View {
switch status { switch status {
case .sending: case .sending:
Image(systemName: "circle") Image(systemName: "circle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .sent: case .sent:
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
case .carried: case .delivered(let nickname, _):
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
case .delivered:
HStack(spacing: -2) { HStack(spacing: -2) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
@@ -95,22 +76,24 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
} }
.foregroundColor(textColor.opacity(0.8)) .foregroundColor(textColor.opacity(0.8))
.help(Strings.delivered(to: nickname))
case .read:
// Filled variant so read vs delivered is legible without color. case .read(let nickname, _):
HStack(spacing: 0) { HStack(spacing: -2) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 9, weight: .bold)) .font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark")
.font(.bitchatSystem(size: 9, weight: .bold)) .font(.bitchatSystem(size: 10, weight: .bold))
} }
.foregroundColor(palette.accentBlue) .foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed:
case .failed(let reason):
Image(systemName: "exclamationmark.triangle") Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8)) .foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total): case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) { HStack(spacing: 1) {
Image(systemName: "checkmark") Image(systemName: "checkmark")
@@ -119,6 +102,7 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10) .bitchatFont(size: 10)
} }
.foregroundColor(secondaryTextColor.opacity(0.6)) .foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
} }
} }
} }
@@ -127,7 +111,6 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.sending, .sending,
.sent, .sent,
.carried,
.delivered(to: "John Doe", at: Date()), .delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()), .read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"), .failed(reason: "Offline"),
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary } private var fgColor: Color { palette.primary }
private var bgColor: Color { private var bgColor: Color {
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12) colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
} }
private var border: Color { fgColor.opacity(0.25) } private var border: Color { fgColor.opacity(0.25) }
@@ -1,29 +0,0 @@
//
// 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"))
}
}
+5 -53
View File
@@ -12,7 +12,6 @@ import BitFoundation
struct TextMessageView: View { struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme @Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
@@ -25,7 +24,6 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff. /// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) { init(message: BitchatMessage) {
self.message = message self.message = message
@@ -37,59 +35,19 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row // Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks() let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
// Baseline alignment keeps the lock and delivery glyphs on the HStack(alignment: .top, spacing: 0) {
// 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 isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) 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)) Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable: // Delivery status indicator for private messages
// .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), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
Button { DeliveryStatusView(status: status)
showDeliveryDetail.toggle() .padding(.leading, 4)
} 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)
} }
} }
@@ -102,7 +60,7 @@ struct TextMessageView: View {
else { expandedMessageIDs.insert(message.id) } else { expandedMessageIDs.insert(message.id) }
} }
.bitchatFont(size: 11, weight: .medium) .bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.accentBlue) .foregroundColor(Color.blue)
.padding(.top, 4) .padding(.top, 4)
} }
@@ -120,12 +78,6 @@ struct TextMessageView: View {
.padding(.leading, 2) .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
}
} }
} }
+8 -67
View File
@@ -6,7 +6,6 @@ import UIKit
struct ContentComposerView: View { struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette @ThemedPalette private var palette
@@ -44,6 +43,7 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
} }
} }
.themedOverlayPanel() .themedOverlayPanel()
@@ -60,8 +60,10 @@ struct ContentComposerView: View {
TextField( TextField(
"", "",
text: $messageText, text: $messageText,
prompt: Text(placeholderText) prompt: Text(
.foregroundColor(palette.secondary.opacity(0.6)) String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(palette.secondary.opacity(0.6))
) )
.textFieldStyle(.plain) .textFieldStyle(.plain)
.bitchatFont(size: 15) .bitchatFont(size: 15)
@@ -108,33 +110,6 @@ struct ContentComposerView: View {
} }
private extension ContentComposerView { 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 { var recordingIndicator: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill") Image(systemName: "waveform.circle.fill")
@@ -183,19 +158,7 @@ private extension ContentComposerView {
imagePickerSourceType = .camera imagePickerSourceType = .camera
showImagePicker = true showImagePicker = true
} }
.accessibilityLabel( .accessibilityLabel("Tap for library, long press for camera")
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 #else
Button(action: { showMacImagePicker = true }) { Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill") Image(systemName: "photo.circle.fill")
@@ -204,9 +167,7 @@ private extension ContentComposerView {
.frame(width: 36, height: 36) .frame(width: 36, height: 36)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel("Choose photo")
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
#endif #endif
} }
@@ -248,27 +209,7 @@ private extension ContentComposerView {
} }
) )
) )
.accessibilityLabel( .accessibilityLabel("Hold to record a voice note")
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 { func sendButtonView(enabled: Bool) -> some View {
+11 -61
View File
@@ -22,9 +22,6 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
Text(verbatim: "bitchat/") Text(verbatim: "bitchat/")
@@ -36,16 +33,6 @@ struct ContentHeaderView: View {
.onTapGesture(count: 1) { .onTapGesture(count: 1) {
appChromeModel.presentAppInfo() 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) { HStack(spacing: 0) {
Text(verbatim: "@") Text(verbatim: "@")
@@ -91,23 +78,6 @@ struct ContentHeaderView: View {
}() }()
HStack(spacing: 2) { 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 { if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) { Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -213,13 +183,6 @@ struct ContentHeaderView: View {
headerOtherPeersCount 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) .layoutPriority(3)
.sheet(isPresented: $showVerifySheet) { .sheet(isPresented: $showVerifySheet) {
@@ -227,16 +190,8 @@ struct ContentHeaderView: View {
.environmentObject(verificationModel) .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) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) { .sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel) .environmentObject(locationChannelsModel)
@@ -311,25 +266,14 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1 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) { func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel { switch locationChannelsModel.selectedChannel {
case .location: case .location:
let count = peerListModel.visibleGeohashPeerCount let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : palette.secondary) return (count, count > 0 ? palette.locationAccent : Color.secondary)
case .mesh: case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
return (peerListModel.reachableMeshPeerCount, color) return (peerListModel.reachableMeshPeerCount, color)
} }
} }
@@ -349,10 +293,16 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title") Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold) .bitchatFont(size: 16, weight: .bold)
Spacer() Spacer()
SheetCloseButton { showLocationNotes = false } Button(action: { showLocationNotes = false }) {
.foregroundColor(palette.primary) Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
} }
.frame(minHeight: headerHeight) .frame(height: headerHeight)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.themedChromePanel(edge: .top) .themedChromePanel(edge: .top)
Text("content.notes.location_unavailable") Text("content.notes.location_unavailable")
+20 -104
View File
@@ -134,7 +134,6 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var appChromeModel: AppChromeModel @EnvironmentObject private var appChromeModel: AppChromeModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel @EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel @EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -160,23 +159,24 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
} }
.buttonStyle(.plain) .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( .help(
String(localized: "content.help.verification", comment: "Help text for verification button") String(localized: "content.help.verification", comment: "Help text for verification button")
) )
} }
SheetCloseButton { Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss() dismiss()
showSidebar = false showSidebar = false
showVerifySheet = false showVerifySheet = false
privateConversationModel.endConversation() privateConversationModel.endConversation()
} }
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
let activeText = String.localizedStringWithFormat( let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle) Text(subtitle)
.foregroundColor(subtitleColor) .foregroundColor(subtitleColor)
Text(activeText) Text(activeText)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
.bitchatFont(size: 12) .bitchatFont(size: 12)
} else { } else {
Text(activeText) Text(activeText)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -231,13 +231,6 @@ private struct ContentPeopleListView: View {
}, },
onShowFingerprint: { peerID in onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID) 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)
}
} }
) )
} }
@@ -340,9 +333,6 @@ private struct ContentPrivateChatSheetView: View {
Image(systemName: headerState.isFavorite ? "star.fill" : "star") Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14)) .font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary) .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) .buttonStyle(.plain)
.accessibilityLabel( .accessibilityLabel(
@@ -356,20 +346,24 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0) Spacer(minLength: 0)
SheetCloseButton { Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation() privateConversationModel.endConversation()
showSidebar = true showSidebar = true
} }
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
} }
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
// minHeight so scaled text at accessibility sizes grows the .frame(height: headerHeight)
// bar instead of clipping inside it.
.frame(minHeight: headerHeight)
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 10) .padding(.top, 10)
.padding(.bottom, 12) .padding(.bottom, 12)
.modifier(PrivateHeaderChrome()) .themedSurface()
} }
MessageListView( MessageListView(
@@ -391,8 +385,6 @@ private struct ContentPrivateChatSheetView: View {
Divider() Divider()
} }
privacyCaption
#if os(iOS) #if os(iOS)
ContentComposerView( ContentComposerView(
messageText: $messageText, messageText: $messageText,
@@ -429,69 +421,6 @@ 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 { private struct ContentPrivateHeaderInfoButton: View {
@@ -523,30 +452,17 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple) .foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")) .accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline: case .offline:
// Absence of a glyph was the only offline signal; say it. EmptyView()
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
} }
Text(headerState.displayName) Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium) .bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary) .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, if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon { let icon = encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 14)) .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( .foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary ? palette.primary
@@ -573,6 +489,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint( .accessibilityHint(
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint") String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
) )
.frame(minHeight: headerHeight) .frame(height: headerHeight)
} }
} }
+8 -5
View File
@@ -54,8 +54,11 @@ struct FingerprintView: View {
Spacer() Spacer()
SheetCloseButton { dismiss() } Button(action: { dismiss() }) {
.foregroundColor(textColor) Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
} }
.padding() .padding()
@@ -80,7 +83,7 @@ struct FingerprintView: View {
Spacer() Spacer()
} }
.padding() .padding()
.background(palette.secondary.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
// Their fingerprint // Their fingerprint
@@ -98,7 +101,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { Button(Strings.copy) {
@@ -132,7 +135,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.padding() .padding()
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1)) .background(Color.gray.opacity(0.1))
.cornerRadius(8) .cornerRadius(8)
.contextMenu { .contextMenu {
Button(Strings.copy) { Button(Strings.copy) {
+1 -45
View File
@@ -13,13 +13,6 @@ struct GeohashPeopleList: View {
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") 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 unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block" 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 { var body: some View {
@@ -53,12 +46,7 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse" let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark) let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon) Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
// 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() let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) { HStack(spacing: 0) {
@@ -66,8 +54,6 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular) .fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor) .foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6) let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix) Text(suffix)
@@ -119,27 +105,6 @@ 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 // Seed and update order outside result builder
@@ -154,13 +119,4 @@ 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: ", ")
}
} }
+20 -38
View File
@@ -31,9 +31,6 @@ struct LocationChannelsSheet: View {
static let toggleOff: LocalizedStringKey = "common.toggle.off" 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 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 { static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row") let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@@ -106,7 +103,7 @@ struct LocationChannelsSheet: View {
} }
Text(Strings.description) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
Group { Group {
switch locationChannelsModel.permissionState { switch locationChannelsModel.permissionState {
@@ -125,7 +122,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open) Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
@@ -172,7 +169,13 @@ struct LocationChannelsSheet: View {
} }
private var closeButton: some View { private var closeButton: some View {
SheetCloseButton { isPresented = false } Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
} }
private var channelList: some View { private var channelList: some View {
@@ -207,10 +210,7 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .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.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel)) locationChannelsModel.select(ChannelID.location(channel))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) { HStack(spacing: 2) {
Text(verbatim: "#") Text(verbatim: "#")
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS) #if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14) .bitchatFont(size: 14)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 10) .padding(.horizontal, 10)
.background(palette.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(6) .cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4) .opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid) .disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked) Text(Strings.bookmarked)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count) let level = levelForLength(gh.count)
@@ -357,10 +357,7 @@ struct LocationChannelsSheet: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.padding(.leading, 8) .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 } let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty { if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@@ -402,8 +399,6 @@ struct LocationChannelsSheet: View {
titleColor: Color? = nil, titleColor: Color? = nil,
titleBold: Bool = false, titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, @ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
@@ -414,17 +409,17 @@ struct LocationChannelsSheet: View {
Text(parts.base) Text(parts.base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular) .fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? palette.primary) .foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty { if let count = parts.countSuffix, !count.isEmpty {
Text(count) Text(count)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
} }
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName) let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull) Text(subtitleFull)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
.truncationMode(.tail) .truncationMode(.tail)
} }
@@ -439,19 +434,6 @@ struct LocationChannelsSheet: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture(perform: action) .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]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -495,16 +477,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle) Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold) .bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary) .foregroundColor(.primary)
Text(Strings.torSubtitle) Text(Strings.torSubtitle)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
} }
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff)) .toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
} }
.padding(12) .padding(12)
.background(palette.secondary.opacity(0.12)) .background(Color.secondary.opacity(0.12))
.cornerRadius(8) .cornerRadius(8)
} }
+14 -7
View File
@@ -30,6 +30,7 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 } private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings { 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 description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent" static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused" static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -96,7 +97,13 @@ struct LocationNotesView: View {
} }
private var closeButton: some View { private var closeButton: some View {
SheetCloseButton { dismiss() } Button(action: { dismiss() }) {
Image(systemName: "xmark")
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.closeAccessibility)
} }
private var headerSection: some View { private var headerSection: some View {
@@ -119,12 +126,12 @@ struct LocationNotesView: View {
} }
Text(Strings.description) Text(Strings.description)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays { if manager.state == .noRelays {
Text(Strings.relaysPaused) Text(Strings.relaysPaused)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
@@ -173,7 +180,7 @@ struct LocationNotesView: View {
if !ts.isEmpty { if !ts.isEmpty {
Text(ts) Text(ts)
.bitchatFont(size: 11) .bitchatFont(size: 11)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
Spacer() Spacer()
} }
@@ -190,7 +197,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint) Text(Strings.relaysRetryHint)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() } Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12) .bitchatFont(size: 12)
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -203,7 +210,7 @@ struct LocationNotesView: View {
ProgressView() ProgressView()
Text(Strings.loadingNotes) Text(Strings.loadingNotes)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
} }
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -215,7 +222,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold) .bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle) Text(Strings.emptySubtitle)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.secondary)
} }
.padding(.vertical, 6) .padding(.vertical, 6)
} }
+19 -127
View File
@@ -9,7 +9,6 @@ private typealias PlatformImage = NSImage
#endif #endif
struct BlockRevealImageView: View { struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL private let url: URL
private let revealProgress: Double? private let revealProgress: Double?
private let isSending: Bool private let isSending: Bool
@@ -21,25 +20,6 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage? @State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1 @State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false @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( init(
url: URL, url: URL,
@@ -89,32 +69,20 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35)) .fill(Color.black.opacity(0.35))
.overlay( .overlay(
VStack(spacing: 6) { Image(systemName: "eye.slash.fill")
Image(systemName: "eye.slash.fill") .font(.bitchatSystem(size: 24, weight: .semibold))
.font(.bitchatSystem(size: 24, weight: .semibold)) .foregroundColor(.white.opacity(0.85))
Text(verbatim: Strings.tapToReveal)
// Themed: monospaced under matrix,
// system under liquid glass.
.bitchatFont(size: 12, weight: .medium)
}
.foregroundColor(.white.opacity(0.85))
) )
} }
} }
} else { } else {
RoundedRectangle(cornerRadius: 16, style: .continuous) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(palette.secondary.opacity(0.2)) .fill(Color.gray.opacity(0.2))
.frame(height: 200) .frame(height: 200)
.overlay { .overlay(
if loadFailed { ProgressView()
Image(systemName: "photo") .progressViewStyle(.circular)
.font(.bitchatSystem(size: 24, weight: .semibold)) )
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
} }
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
@@ -127,7 +95,6 @@ struct BlockRevealImageView: View {
.padding(8) .padding(8)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
} }
} }
.onAppear { .onAppear {
@@ -139,105 +106,30 @@ struct BlockRevealImageView: View {
loadImage() loadImage()
} }
.gesture(mainGesture) .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() { private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async { DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS) #if os(iOS)
let image = UIImage(contentsOfFile: url.path) guard let image = UIImage(contentsOfFile: url.path) else { return }
#else #else
let image = NSImage(contentsOf: url) guard let image = NSImage(contentsOf: url) else { return }
#endif #endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async { DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image self.platformImage = image
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1 self.aspectRatio = ratio
} }
} }
} }
// 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 { private var mainGesture: some Gesture {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
}
let singleTap = TapGesture().onEnded { let singleTap = TapGesture().onEnded {
guard !isSending, !loadFailed else { return } guard !isSending else { return }
if isBlurred { if isBlurred {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false isBlurred = false
@@ -247,7 +139,7 @@ struct BlockRevealImageView: View {
} }
} }
let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in let swipe = DragGesture(minimumDistance: 20, coordinateSpace: .local).onEnded { value in
guard !isSending, !loadFailed else { return } guard !isSending else { return }
let horizontal = value.translation.width let horizontal = value.translation.width
let vertical = value.translation.height let vertical = value.translation.height
guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return } guard abs(horizontal) > abs(vertical), abs(horizontal) > 40 else { return }
@@ -257,7 +149,7 @@ struct BlockRevealImageView: View {
} }
} }
} }
return singleTap.simultaneously(with: swipe) return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
} }
} }
+33 -82
View File
@@ -11,7 +11,6 @@ import BitFoundation
struct MediaMessageView: View { struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme @Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage let message: BitchatMessage
let media: BitchatMessage.Media let media: BitchatMessage.Media
@@ -21,7 +20,6 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change /// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row. /// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus? private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL? @Binding var imagePreviewURL: URL?
@@ -37,93 +35,46 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message) let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
// Baseline alignment (via the header text inside the VStack) keeps the VStack(alignment: .leading, spacing: 2) {
// lock on the header line; a fixed top padding left its solid body HStack(alignment: .center, spacing: 4) {
// hanging below the line's visual center. Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
HStack(alignment: .firstTextBaseline, spacing: 0) { .fixedSize(horizontal: false, vertical: true)
if message.isPrivate { .frame(maxWidth: .infinity, alignment: .leading)
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), if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus { let status = deliveryStatus {
if case .failed = status { DeliveryStatusView(status: status)
Text(verbatim: status.bitchatDescription) .padding(.leading, 4)
.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)
}
} }
}
Group { Group {
switch media { switch media {
case .voice(let url): case .voice(let url):
VoiceNoteView( VoiceNoteView(
url: url, url: url,
isSending: state.isSending, isSending: state.isSending,
sendProgress: state.progress, sendProgress: state.progress,
onCancel: cancelAction onCancel: cancelAction
) )
case .image(let url): case .image(let url):
BlockRevealImageView( BlockRevealImageView(
url: url, url: url,
revealProgress: state.progress, revealProgress: state.progress,
isSending: state.isSending, isSending: state.isSending,
onCancel: cancelAction, onCancel: cancelAction,
initiallyBlurred: !isFromMe, initiallyBlurred: !isFromMe,
onOpen: { onOpen: {
if !state.isSending { if !state.isSending {
imagePreviewURL = url imagePreviewURL = url
} }
}, },
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
) )
.frame(maxWidth: 280) .frame(maxWidth: 280)
}
} }
} }
} }
.padding(.vertical, 4) .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) { private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
@@ -139,7 +90,7 @@ struct MediaMessageView: View {
isSending = true isSending = true
progress = Double(reached) / Double(total) progress = Double(reached) / Double(total)
} }
case .sent, .carried, .read, .delivered, .failed: case .sent, .read, .delivered, .failed:
break break
} }
} }
+2 -13
View File
@@ -28,9 +28,7 @@ struct VoiceNoteView: View {
} }
private var backgroundColor: Color { private var backgroundColor: Color {
// Palette-based and slightly translucent so the card doesn't sit as colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
// an opaque white/black box over the glass gradient.
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
} }
private var borderColor: Color { private var borderColor: Color {
@@ -52,12 +50,6 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent)) .background(Circle().fill(palette.accent))
} }
.buttonStyle(.plain) .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( WaveformView(
samples: samples, samples: samples,
@@ -71,7 +63,7 @@ struct VoiceNoteView: View {
Text(playbackLabel) Text(playbackLabel)
.bitchatFont(size: 13) .bitchatFont(size: 13)
.foregroundColor(palette.secondary) .foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending { if let onCancel = onCancel, isSending {
Button(action: onCancel) { Button(action: onCancel) {
@@ -82,9 +74,6 @@ struct VoiceNoteView: View {
.foregroundColor(.white) .foregroundColor(.white)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
} }
} }
.padding(12) .padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send { } else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue color = palette.accentBlue
} else { } else {
color = palette.secondary.opacity(0.35) color = Color.gray.opacity(0.35)
} }
context.fill(Path(rect), with: .color(color)) context.fill(Path(rect), with: .color(color))
} }
+1 -100
View File
@@ -7,9 +7,6 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (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 @Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = [] @State private var orderedIDs: [String] = []
@@ -18,20 +15,6 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" 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 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 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 { var body: some View {
@@ -66,25 +49,21 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right") Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable { } else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon // Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted") Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(baseColor) .foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite { } else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple) // Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe") Image(systemName: "globe")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(.purple) .foregroundColor(.purple)
.help(Strings.nostr)
} else { } else {
// Fallback icon for others (dimmed) // Fallback icon for others (dimmed)
Image(systemName: "person") Image(systemName: "person")
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary) .foregroundColor(palette.secondary)
.help(Strings.offline)
} }
let (base, suffix) = peer.displayName.splitSuffix() let (base, suffix) = peer.displayName.splitSuffix()
@@ -92,8 +71,6 @@ struct MeshPeerList: View {
Text(base) Text(base)
.bitchatFont(size: 14) .bitchatFont(size: 14)
.foregroundColor(baseColor) .foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty { if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6) let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix) Text(suffix)
@@ -114,10 +91,6 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon { if let icon = peer.encryptionStatus.icon {
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .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) .foregroundColor(baseColor)
} }
} else { } else {
@@ -130,7 +103,6 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session) // Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon) Image(systemName: icon)
.font(.bitchatSystem(size: 10)) .font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor) .foregroundColor(baseColor)
} }
} }
@@ -151,11 +123,6 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star") Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12)) .font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary) .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) .buttonStyle(.plain)
} }
@@ -164,53 +131,8 @@ struct MeshPeerList: View {
.padding(.vertical, 4) .padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0) .padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle()) .contentShape(Rectangle())
// 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) } } .onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
.contextMenu { .onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
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 // Seed and update order outside result builder
@@ -225,25 +147,4 @@ 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: ", ")
}
} }
+24 -241
View File
@@ -36,17 +36,8 @@ struct MessageListView: View {
var isTextFieldFocused: FocusState<Bool>.Binding var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false @State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast @State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer? @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 { var body: some View {
let currentWindowCount: Int = { let currentWindowCount: Int = {
@@ -74,9 +65,6 @@ struct MessageListView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) { LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in ForEach(messageItems) { item in
let message = item.message let message = item.message
@@ -84,7 +72,6 @@ struct MessageListView: View {
.onAppear { .onAppear {
if message.id == windowedMessages.last?.id { if message.id == windowedMessages.last?.id {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
if message.id == windowedMessages.first?.id, if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count { messages.count > windowedMessages.count {
@@ -102,32 +89,13 @@ struct MessageListView: View {
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.contextMenu { .onTapGesture {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message) if message.sender != "system" {
if showsUserActions { messageText = "@\(message.sender) "
// Mention and DM are redundant inside a 1:1 conversation: isTextFieldFocused.wrappedValue = true
// 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)
}
} }
}
.contextMenu {
Button("content.message.copy") { Button("content.message.copy") {
#if os(iOS) #if os(iOS)
UIPasteboard.general.string = message.content UIPasteboard.general.string = message.content
@@ -137,16 +105,6 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string) pb.setString(message.content, forType: .string)
#endif #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(.horizontal, 12)
.padding(.vertical, 1) .padding(.vertical, 1)
@@ -155,24 +113,9 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } } .transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2) .padding(.vertical, 2)
} }
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
showClearConfirmation = true conversationUIModel.clearCurrentConversation()
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
} }
.onAppear { .onAppear {
scrollToBottom(on: proxy) scrollToBottom(on: proxy)
@@ -196,7 +139,9 @@ struct MessageListView: View {
) { ) {
Button("content.actions.mention") { Button("content.actions.mention") {
if let sender = selectedMessageSender { if let sender = selectedMessageSender {
insertMention(sender) // Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
} }
} }
@@ -263,142 +208,6 @@ struct MessageListView: View {
} }
private extension MessageListView { 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 @ViewBuilder
func messageRow(for message: BitchatMessage) -> some View { func messageRow(for message: BitchatMessage) -> some View {
Group { Group {
@@ -485,9 +294,6 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) { func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID { if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom) proxy.scrollTo(targetPeerID, anchor: .bottom)
} }
@@ -510,23 +316,15 @@ private extension MessageListView {
} }
func onMessagesChange(proxy: ScrollViewProxy) { func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages let messages = publicChatModel.messages
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages)) guard privatePeer == nil, let lastMsg = messages.last else { return }
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 // If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { // Ensure we consider ourselves at bottom for subsequent messages } else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -554,23 +352,18 @@ private extension MessageListView {
} }
func onPrivateChatsChange(proxy: ScrollViewProxy) { func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer else { return } guard let peerID = privatePeer,
let messages = privateInboxModel.messages(for: peerID) let lastMsg = privateInboxModel.messages(for: peerID).last else {
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 return
} }
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll // If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg) let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return return
} else { } else {
isAtBottom = true isAtBottom = true
unseenCount = 0
} }
func scrollIfNeeded(date: Date) { func scrollIfNeeded(date: Date) {
@@ -598,27 +391,17 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) { func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom // When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return } guard privatePeer == nil else { return }
// 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: String
switch channel { switch channel {
case .mesh: case .mesh:
contextKey = "mesh" break
case .location(let ch): case .location(let ch):
contextKey = "geo:\(ch.geohash)" // Reset window size
} isAtBottom = true
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) { windowCountPublic = TransportConfig.uiWindowInitialCountPublic
proxy.scrollTo(target, anchor: .bottom) let contextKey = "geo:\(ch.geohash)"
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
} }
} }
@@ -643,6 +426,6 @@ private extension ChannelID {
} }
} }
// #Preview { //#Preview {
// MessageListView() // MessageListView()
// } //}
+12 -13
View File
@@ -11,10 +11,7 @@ import AppKit
struct MyQRView: View { struct MyQRView: View {
let qrString: String let qrString: String
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette private var boxColor: Color { Color.gray.opacity(0.1) }
// 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 { private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title" static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -53,7 +50,6 @@ struct MyQRView: View {
struct QRCodeImage: View { struct QRCodeImage: View {
let data: String let data: String
let size: CGFloat let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext() private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator() private let filter = CIFilter.qrCodeGenerator()
@@ -69,12 +65,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size) .frame(width: size, height: size)
} else { } else {
RoundedRectangle(cornerRadius: 8) RoundedRectangle(cornerRadius: 8)
.stroke(palette.secondary.opacity(0.5), lineWidth: 1) .stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay( .overlay(
Text(Strings.unavailable) Text(Strings.unavailable)
.bitchatFont(size: 12) .bitchatFont(size: 12)
.foregroundColor(palette.secondary) .foregroundColor(.gray)
) )
} }
} }
@@ -112,7 +108,6 @@ struct ImageWrapper: View {
/// Placeholder scanner UI; real camera scanning will be added later. /// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View { struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = "" @State private var input = ""
@@ -158,7 +153,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium) .bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input) TextEditor(text: $input)
.frame(height: 100) .frame(height: 100)
.border(palette.secondary.opacity(0.4)) .border(Color.gray.opacity(0.4))
Button(Strings.validate) { Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR // Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else { guard input != lastValid else {
@@ -270,7 +265,7 @@ struct CameraScannerView: UIViewRepresentable {
} }
final class PreviewView: UIView { final class PreviewView: UIView {
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self } override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer } var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
@@ -290,7 +285,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background } private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent } private var accentColor: Color { palette.accent }
private var boxColor: Color { palette.secondary.opacity(0.1) } private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -300,11 +295,15 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold) .bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor) .foregroundColor(accentColor)
Spacer() Spacer()
SheetCloseButton { Button(action: {
showingScanner = false showingScanner = false
isPresented = false isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
} }
.foregroundColor(accentColor) .buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 12) .padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty { if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text // 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) self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else { } else {
self.saveAndFinish(text: t) self.saveAndFinish(text: t)
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [ context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice", PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob", PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me" PeerID(str: "3333333333333333"): "me",
] ]
// Matching query: suggestions and range are published, index resets. // Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol", "aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy // Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222". // identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me" "ffffeeeeddddcccc2222": "me",
] ]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3) coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent. // Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent), makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2) makePrivateMessage(id: "m2", timestamp: t2),
] ]
context.privateChats[stablePeerID] = [ 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) let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey), makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey), makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true), 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) coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
) )
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: 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) coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer, peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32), noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice" nickname: "alice"
) ),
] ]
context.unreadPrivateMessages = [ context.unreadPrivateMessages = [
currentPeer, currentPeer,
staleShortPeer, staleShortPeer,
geoDMWithMessages, geoDMWithMessages,
geoDMWithoutMessages, geoDMWithoutMessages,
noiseKeyWithMessages noiseKeyWithMessages,
] ]
context.privateChats = [ context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")], geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")] noiseKeyWithMessages: [makeMessage(id: "noise-1")],
] ]
coordinator.didUpdatePeerList([currentPeer]) coordinator.didUpdatePeerList([currentPeer])
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234" context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [ context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"), makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me") makeIncomingMessage(id: "mine-2", sender: "me"),
] ]
coordinator.handleDelivered( coordinator.handleDelivered(
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID), makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID), 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) coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks() await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID] context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [ context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: 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) coordinator.didDisconnectFromPeer(peerID)
@@ -428,13 +428,12 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor @Test @MainActor
func statusRank_orderingIsCorrect() async { func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages // This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < carried < partiallyDelivered < delivered < read // failed < sending < sent < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [ let statuses: [DeliveryStatus] = [
.failed(reason: "test"), .failed(reason: "test"),
.sending, .sending,
.sent, .sent,
.carried,
.partiallyDelivered(reached: 1, total: 3), .partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()), .delivered(to: "B", at: Date()),
.read(by: "C", at: Date()) .read(by: "C", at: Date())
@@ -447,10 +446,9 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0) case .failed: #expect(index == 0)
case .sending: #expect(index == 1) case .sending: #expect(index == 1)
case .sent: #expect(index == 2) case .sent: #expect(index == 2)
case .carried: #expect(index == 3) case .partiallyDelivered: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4) case .delivered: #expect(index == 4)
case .delivered: #expect(index == 5) case .read: #expect(index == 5)
case .read: #expect(index == 6)
} }
} }
} }
@@ -297,23 +297,8 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({ let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true } return viewModel.messages.contains { $0.content == "Hello Geo" }
// 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) #expect(didAppend)
} }
@@ -1015,11 +1000,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url) viewModel.sendVoiceNote(at: url)
// Media sends hop through Task.detached; the global executor is let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
// 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(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true) #expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1039,7 +1020,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({ let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus) isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: TestConstants.longTimeout) }, timeout: 5.0)
#expect(didFail) #expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path)) #expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
@@ -1055,7 +1036,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL) viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout) let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
#expect(didSend) #expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID) #expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg") #expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1076,7 +1057,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({ let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") }) viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: TestConstants.longTimeout) }, timeout: 5.0)
#expect(didNotify) #expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty) #expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false) #expect(viewModel.privateChats[peerID]?.isEmpty != false)
-53
View File
@@ -263,59 +263,6 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty) #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 // MARK: - Composer Tests
-176
View File
@@ -1,176 +0,0 @@
//
// 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])
}
}
@@ -1,636 +0,0 @@
//
// 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 // Encrypted path: use NoiseSessionManager explicitly
let plaintext = Data("Encrypted message".utf8) let plaintext = "Encrypted message".data(using: .utf8)!
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID) let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages // David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { _ in helper.nodes["David"]!.messageDeliveryHandler = { message in
completion() completion()
} }
@@ -288,7 +288,7 @@ struct IntegrationTests {
} }
do { do {
let plaintext = Data("After restart success".utf8) let plaintext = "After restart success".data(using: .utf8)!
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID) let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext) let packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,3 +121,4 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3) _ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
} }
} }
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [ @Test(arguments: [
// === Image types === // === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]), (MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), (MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a" (MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP" 0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types === // === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3" (MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, (MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE" 0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS" (MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types === // === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF" (MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
]) ])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws { func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes) let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(), Data(),
Data(repeating: 0x00, count: 32), Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)), Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32) Data(repeating: 0xFF, count: 32),
] ]
for invalidKey in invalidKeys { for invalidKey in invalidKeys {
+28 -27
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws { @Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Hello, Bob!".utf8) let plaintext = "Hello, Bob!".data(using: .utf8)!
// Alice encrypts // Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob // Alice -> Bob
let aliceMessage = Data("Hello from Alice".utf8) let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceCiphertext = try aliceSession.encrypt(aliceMessage) let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext) let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage) #expect(bobReceived == aliceMessage)
// Bob -> Alice // Bob -> Alice
let bobMessage = Data("Hello from Bob".utf8) let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobCiphertext = try bobSession.encrypt(bobMessage) let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext) let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage) #expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
} }
@Test func encryptionBeforeHandshake() { @Test func encryptionBeforeHandshake() {
let plaintext = Data("test".utf8) let plaintext = "test".data(using: .utf8)!
#expect(throws: NoiseSessionError.notEstablished) { #expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext) try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager // Encrypt with manager
let plaintext = Data("Test message".utf8) let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID) let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager // Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws { @Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Secret message".utf8) let plaintext = "Secret message".data(using: .utf8)!
var ciphertext = try aliceSession.encrypt(plaintext) var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext // Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws { @Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession) try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Test message".utf8) let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceSession.encrypt(plaintext) let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed // First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2) try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1 // Encrypt with session 1
let plaintext = Data("Secret".utf8) let plaintext = "Secret".data(using: .utf8)!
let ciphertext1 = try aliceSession1.encrypt(plaintext) let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2 // Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state // Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID) let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(message1, from: bobPeerID) _ = try bobManager.decrypt(message1, from: bobPeerID)
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID) let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
_ = try aliceManager.decrypt(message2, from: alicePeerID) _ = try aliceManager.decrypt(message2, from: alicePeerID)
// Simulate Bob restart by creating new manager with same key // Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions // Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8) let testMessage = "After restart".data(using: .utf8)!
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID) let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID) let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces // Exchange messages to advance nonces
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8)) let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
_ = try bobSession.decrypt(msg) _ = try bobSession.decrypt(msg)
} }
// Simulate desynchronization by encrypting but not decrypting // Simulate desynchronization by encrypting but not decrypting
for i in 0..<3 { for i in 0..<3 {
_ = try aliceSession.encrypt(Data("Lost message \(i)".utf8)) _ = try aliceSession.encrypt("Lost message \(i)".data(using: .utf8)!)
} }
// With per-packet nonce carried, decryption should not throw here // With per-packet nonce carried, decryption should not throw here
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8)) let desyncMessage = try aliceSession.encrypt("This now succeeds".data(using: .utf8)!)
#expect(throws: Never.self) { #expect(throws: Never.self) {
try bobSession.decrypt(desyncMessage) try bobSession.decrypt(desyncMessage)
} }
@@ -434,11 +434,12 @@ struct NoiseProtocolTests {
let messageCount = 100 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] = [:] var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager // Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount { for i in 0..<messageCount {
let plaintext = Data("Concurrent message \(i)".utf8) let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID) let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted encryptedMessages[i] = encrypted
} }
@@ -451,7 +452,7 @@ struct NoiseProtocolTests {
return return
} }
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
let expected = Data("Concurrent message \(i)".utf8) let expected = "Concurrent message \(i)".data(using: .utf8)!
#expect(decrypted == expected) #expect(decrypted == expected)
completion() completion()
} catch { } catch {
@@ -484,7 +485,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager) try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Create a corrupted message // Create a corrupted message
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID) var encrypted = try aliceManager.encrypt("Test".data(using: .utf8)!, for: alicePeerID)
encrypted[10] ^= 0xFF // Corrupt the data encrypted[10] ^= 0xFF // Corrupt the data
// Decryption should fail // Decryption should fail
@@ -515,7 +516,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true) #expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// Exchange messages to verify sessions work // Exchange messages to verify sessions work
let testMessage = Data("Session works".utf8) let testMessage = "Session works".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID) let encrypted = try aliceManager.encrypt(testMessage, for: alicePeerID)
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID) let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage) #expect(decrypted == testMessage)
@@ -538,7 +539,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!) _ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// Verify new sessions work // Verify new sessions work
let testMessage2 = Data("New session works".utf8) let testMessage2 = "New session works".data(using: .utf8)!
let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID) let encrypted2 = try aliceManager.encrypt(testMessage2, for: alicePeerID)
let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID) let decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2) #expect(decrypted2 == testMessage2)
@@ -554,18 +555,18 @@ struct NoiseProtocolTests {
// Exchange messages normally // Exchange messages normally
for i in 0..<5 { for i in 0..<5 {
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID) let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
_ = try bobManager.decrypt(msg, from: bobPeerID) _ = try bobManager.decrypt(msg, from: bobPeerID)
} }
// Simulate desynchronization - Alice sends messages that Bob doesn't receive // Simulate desynchronization - Alice sends messages that Bob doesn't receive
for i in 0..<3 { for i in 0..<3 {
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID) _ = try aliceManager.encrypt("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
} }
// With nonce carried in packet, decryption should not throw here // With nonce carried in packet, decryption should not throw here
let desyncMessage = try aliceManager.encrypt( let desyncMessage = try aliceManager.encrypt(
Data("This now succeeds".utf8), for: alicePeerID) "This now succeeds".data(using: .utf8)!, for: alicePeerID)
#expect(throws: Never.self) { #expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID) try bobManager.decrypt(desyncMessage, from: bobPeerID)
} }
@@ -586,7 +587,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!) _ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again // Verify communication works again
let testResynced = Data("Resynced".utf8) let testResynced = "Resynced".data(using: .utf8)!
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID) let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID) let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced) #expect(decryptedResync == testResynced)
@@ -899,20 +900,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0) #expect(trackingKeychain.secureClearDataCallCount > 0)
// Test encryption from Alice to Bob // Test encryption from Alice to Bob
let plaintext1 = Data("Hello from Alice after secureClear!".utf8) let plaintext1 = "Hello from Alice after secureClear!".data(using: .utf8)!
let ciphertext1 = try alice.encrypt(plaintext1) let ciphertext1 = try alice.encrypt(plaintext1)
let decrypted1 = try bob.decrypt(ciphertext1) let decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1) #expect(decrypted1 == plaintext1)
// Test encryption from Bob to Alice // Test encryption from Bob to Alice
let plaintext2 = Data("Hello from Bob after secureClear!".utf8) let plaintext2 = "Hello from Bob after secureClear!".data(using: .utf8)!
let ciphertext2 = try bob.encrypt(plaintext2) let ciphertext2 = try bob.encrypt(plaintext2)
let decrypted2 = try alice.decrypt(ciphertext2) let decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2) #expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct // Test multiple messages to verify cipher state is correct
for i in 1...10 { for i in 1...10 {
let msg = Data("Message \(i) from Alice".utf8) let msg = "Message \(i) from Alice".data(using: .utf8)!
let cipher = try alice.encrypt(msg) let cipher = try alice.encrypt(msg)
let dec = try bob.decrypt(cipher) let dec = try bob.decrypt(cipher)
#expect(dec == msg) #expect(dec == msg)
-107
View File
@@ -1,107 +0,0 @@
//
// 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))
}
}
+153
View File
@@ -289,6 +289,159 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42) #expect(object["limit"] as? Int == 42)
} }
// MARK: - Padding (v3 envelope)
@Test func paddedLengthMatchesNIP44Buckets() {
// Vectors from the NIP-44 reference test suite (calc_padded_len).
let vectors: [(Int, Int)] = [
(1, 32), (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64),
(64, 64), (65, 96), (100, 128), (111, 128), (200, 224), (250, 256),
(320, 320), (383, 384), (384, 384), (400, 448), (500, 512),
(512, 512), (515, 640), (700, 768), (800, 896), (900, 1024),
(1020, 1024), (65535, 65536)
]
for (unpadded, expected) in vectors {
#expect(
NIP44Padding.paddedLength(for: unpadded) == expected,
"paddedLength(for: \(unpadded)) should be \(expected)"
)
}
}
@Test func padUnpadRoundTrip() throws {
for length in [1, 2, 31, 32, 33, 100, 320, 1020, 4096, 65535] {
let plaintext = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let padded = try NIP44Padding.pad(plaintext)
#expect(padded.count == 2 + NIP44Padding.paddedLength(for: length))
let unpadded = try NIP44Padding.unpad(padded)
#expect(unpadded == plaintext)
}
}
@Test func padHidesExactLengthWithinBucket() throws {
// Two plaintexts of different length in the same bucket must produce
// identically sized padded payloads (and thus ciphertexts).
let short = try NIP44Padding.pad(Data(repeating: 0x41, count: 65))
let long = try NIP44Padding.pad(Data(repeating: 0x42, count: 96))
#expect(short.count == long.count)
}
@Test func padRejectsOutOfRangePlaintexts() {
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data()) }
#expect(throws: (any Error).self) { try NIP44Padding.pad(Data(count: 65536)) }
}
@Test func unpadRejectsTamperedLengthPrefix() throws {
var padded = try NIP44Padding.pad(Data(repeating: 0x41, count: 40))
// Claimed length larger than the actual payload
var tooLong = padded
tooLong[tooLong.startIndex] = 0xFF
tooLong[tooLong.startIndex + 1] = 0xFF
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(tooLong) }
// Claimed length of zero
var zero = padded
zero[zero.startIndex] = 0x00
zero[zero.startIndex + 1] = 0x00
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(zero) }
// Claimed length whose bucket does not match the payload size
// (payload is bucket 64; a claimed length of 20 expects bucket 32)
var wrongBucket = padded
wrongBucket[wrongBucket.startIndex] = 0x00
wrongBucket[wrongBucket.startIndex + 1] = 0x14
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(wrongBucket) }
// Truncated payloads
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data()) }
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(Data([0x00])) }
padded.removeLast()
#expect(throws: NostrError.invalidCiphertext) { try NIP44Padding.unpad(padded) }
// Works on Data slices with non-zero startIndex
let sliced = try (Data([0xAB]) + NIP44Padding.pad(Data(repeating: 0x41, count: 40))).dropFirst()
#expect(try NIP44Padding.unpad(sliced) == Data(repeating: 0x41, count: 40))
}
@Test func paddedEnvelopeRoundTrip_v3() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "padded envelope test"
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: true
)
#expect(ciphertext.hasPrefix("v3:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func legacyUnpaddedEnvelopeStillDecrypts_v2() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let plaintext = "legacy v2 envelope"
// What deployed clients send today.
let ciphertext = try NostrProtocol.encrypt(
plaintext: plaintext,
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
#expect(ciphertext.hasPrefix("v2:"))
let decrypted = try NostrProtocol.decrypt(
ciphertext: ciphertext,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
#expect(decrypted == plaintext)
}
@Test func outgoingMessagesStillUseV2UntilRolloutFlagFlips() throws {
// Deployed clients reject anything that is not "v2:", so the padded
// envelope must stay off by default until decrypt-side support is
// widely shipped (see NostrProtocol.sendPaddedEnvelope).
#expect(NostrProtocol.sendPaddedEnvelope == false)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "default envelope",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
}
@Test func decryptRejectsUnknownEnvelopeVersion() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let ciphertext = try NostrProtocol.encrypt(
plaintext: "test",
recipientPubkey: recipient.publicKeyHex,
senderKey: sender.schnorrSigningKey(),
padded: false
)
let mutated = "v9:" + ciphertext.dropFirst(3)
#expect(throws: NostrError.invalidCiphertext) {
_ = try NostrProtocol.decrypt(
ciphertext: mutated,
senderPubkey: sender.publicKeyHex,
recipientKey: recipient.schnorrSigningKey()
)
}
}
// MARK: - Helpers // MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? { private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]), ("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil), ("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]), ("@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 let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in (0..<batchSize).map { i in
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?", "anyone near the station?",
"@bob#0042 are you on mesh too?", "@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat", "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 return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent( try NostrProtocol.createEphemeralGeohashEvent(
+5 -10
View File
@@ -50,19 +50,14 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests { struct ProtocolContractTests {
@Test @Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() { func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
// Aliases must match what CommandProcessor actually accepts #expect(CommandInfo.message.id == "dm")
// the suggestion panel is the only command-discovery surface. #expect(CommandInfo.message.alias == "/dm")
#expect(CommandInfo.message.id == "msg")
#expect(CommandInfo.message.alias == "/msg")
#expect(CommandInfo.message.placeholder != nil) #expect(CommandInfo.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil) #expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false) #expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help)) #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
// Favorites are rejected by the processor in geohash contexts, so #expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
// they are suggested only in mesh. #expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
#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 @Test
@@ -98,12 +98,8 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) 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.count == 1)
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32)) #expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
@@ -165,11 +161,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result?.peerID == peerID)
#expect(result?.announcement.noisePublicKey == noiseKey)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.isEmpty) #expect(recorder.verifySignatureCalls.isEmpty)
#expect(recorder.barrierCount == 1) #expect(recorder.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
@@ -204,9 +197,8 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false recorder.signatureValid = false
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.verifySignatureCalls.count == 1) #expect(recorder.verifySignatureCalls.count == 1)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -230,9 +222,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -251,9 +242,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now) let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -273,9 +263,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder() let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder) expectNoSideEffects(recorder)
} }
@@ -322,10 +311,8 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil) recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result?.isDirectAnnounce == false)
#expect(result?.isVerified == true)
#expect(recorder.upsertCalls.count == 1) #expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false) #expect(recorder.upsertCalls.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
@@ -397,9 +384,8 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32) recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
let handler = makeHandler(recorder: recorder, now: now) let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID) handler.handle(packet, from: peerID)
#expect(result?.isVerified == false)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1) #expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
@@ -19,79 +19,6 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"])) #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 @Test
func directedSendExcludesAllLinksToIngressPeer() { func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks( let selection = BLEFanoutSelector.selectLinks(
@@ -41,44 +41,6 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID)) #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 { private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,11 +12,6 @@ import Foundation
struct TestConstants { struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0 static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.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 longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice" static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString), senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = Data("test payload".utf8), payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil, signature: Data? = nil,
ttl: UInt8 = 3 ttl: UInt8 = 3
) -> BitchatPacket { ) -> BitchatPacket {
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: Data(senderID.id.utf8), senderID: senderID.id.data(using: .utf8)!,
recipientID: recipientID?.id.data(using: .utf8), recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
+3 -13
View File
@@ -556,19 +556,11 @@ struct ViewSmokeTests {
@Test @Test
func voiceAndMediaViews_renderAndWarmCaches() async throws { func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL() 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() let imageURL = try makeTemporaryImageURL()
defer { defer {
try? FileManager.default.removeItem(at: audioURL) try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL) try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL) WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
} }
let waveformView = WaveformView( let waveformView = WaveformView(
@@ -602,14 +594,12 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView) _ = mount(voiceNoteView)
let bins = await withCheckedContinuation { continuation in let bins = await withCheckedContinuation { continuation in
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
continuation.resume(returning: values) continuation.resume(returning: values)
} }
} }
playback.loadDuration() playback.loadDuration()
// loadDuration hops through a background queue and back to main; poll try? await Task.sleep(nanoseconds: 250_000_000)
// 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.seek(to: 1.25)
playback.stop() playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback) VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -617,7 +607,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording() await VoiceRecorder.shared.cancelRecording()
#expect(bins.count == 16) #expect(bins.count == 16)
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16) #expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
#expect(playback.duration > 0) #expect(playback.duration > 0)
#expect(playback.progress == 0) #expect(playback.progress == 0)
} }
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests { struct XChaCha20Poly1305CompatTests {
@Test func sealAndOpenRoundtrip() throws { @Test func sealAndOpenRoundtrip() throws {
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8) let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealAndOpenWithAAD() throws { @Test func sealAndOpenWithAAD() throws {
let plaintext = Data("Secret message".utf8) let plaintext = "Secret message".data(using: .utf8)!
let key = Data(repeating: 0xAB, count: 32) let key = Data(repeating: 0xAB, count: 32)
let nonce = Data(repeating: 0xCD, count: 24) let nonce = Data(repeating: 0xCD, count: 24)
let aad = Data("additional authenticated data".utf8) let aad = "additional authenticated data".data(using: .utf8)!
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
let decrypted = try XChaCha20Poly1305Compat.open( let decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealProducesDifferentCiphertextWithDifferentNonces() throws { @Test func sealProducesDifferentCiphertextWithDifferentNonces() throws {
let plaintext = Data("Same plaintext".utf8) let plaintext = "Same plaintext".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce1 = Data(repeating: 0x01, count: 24) let nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24) let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortKey() { @Test func sealThrowsOnShortKey() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let shortKey = Data(repeating: 0x42, count: 16) let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongKey() { @Test func sealThrowsOnLongKey() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let longKey = Data(repeating: 0x42, count: 64) let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyKey() { @Test func sealThrowsOnEmptyKey() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let emptyKey = Data() let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnShortNonce() { @Test func sealThrowsOnShortNonce() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12) let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnLongNonce() { @Test func sealThrowsOnLongNonce() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32) let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func sealThrowsOnEmptyNonce() { @Test func sealThrowsOnEmptyNonce() {
let plaintext = Data("Test".utf8) let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data() let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithWrongKey() throws { @Test func openFailsWithWrongKey() throws {
let plaintext = Data("Secret".utf8) let plaintext = "Secret".data(using: .utf8)!
let correctKey = Data(repeating: 0x42, count: 32) let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32) let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
} }
@Test func openFailsWithTamperedCiphertext() throws { @Test func openFailsWithTamperedCiphertext() throws {
let plaintext = Data("Secret".utf8) let plaintext = "Secret".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32) let key = Data(repeating: 0x42, count: 32)
let nonce = Data(repeating: 0x24, count: 24) let nonce = Data(repeating: 0x24, count: 24)
+7 -7
View File
@@ -5,16 +5,16 @@ let package = Package(
name: "Tor", // Keep name "Tor" for drop-in compatibility name: "Tor", // Keep name "Tor" for drop-in compatibility
platforms: [ platforms: [
.iOS(.v16), .iOS(.v16),
.macOS(.v13) .macOS(.v13),
], ],
products: [ products: [
.library( .library(
name: "Tor", name: "Tor",
targets: ["Tor"] targets: ["Tor"]
) ),
], ],
dependencies: [ dependencies: [
.package(path: "../BitLogger") .package(path: "../BitLogger"),
], ],
targets: [ targets: [
// Main Swift target // Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor", name: "Tor",
dependencies: [ dependencies: [
"arti", "arti",
.product(name: "BitLogger", package: "BitLogger") .product(name: "BitLogger", package: "BitLogger"),
], ],
path: "Sources", path: "Sources",
exclude: ["C"], exclude: ["C"],
sources: [ sources: [
"TorManager.swift", "TorManager.swift",
"TorURLSession.swift", "TorURLSession.swift",
"TorNotifications.swift" "TorNotifications.swift",
], ],
linkerSettings: [ linkerSettings: [
.linkedLibrary("resolv"), .linkedLibrary("resolv"),
.linkedLibrary("z"), .linkedLibrary("z"),
.linkedLibrary("sqlite3") .linkedLibrary("sqlite3"),
] ]
), ),
// Binary framework containing the Rust static library. // Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget( .binaryTarget(
name: "arti", name: "arti",
path: "Frameworks/arti.xcframework" path: "Frameworks/arti.xcframework"
) ),
] ]
) )
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target( .target(
name: "BitFoundation", name: "BitFoundation",
dependencies: [ dependencies: [
.product(name: "BitLogger", package: "BitLogger") .product(name: "BitLogger", package: "BitLogger"),
], ],
path: "Sources" path: "Sources"
), ),
@@ -1,147 +0,0 @@
//
// 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)
}
}
}
@@ -8,12 +8,44 @@
import struct Foundation.Data import struct Foundation.Data
/// Lowercase hex digits used by `hexEncodedString()`.
private let hexDigits: [UInt8] = Array("0123456789abcdef".utf8)
/// Maps an ASCII byte to its hex nibble value, or nil for non-hex characters.
/// Accepts both lowercase and uppercase hex digits.
@inline(__always)
private func hexNibble(_ ascii: UInt8) -> UInt8? {
switch ascii {
case UInt8(ascii: "0")...UInt8(ascii: "9"):
return ascii - UInt8(ascii: "0")
case UInt8(ascii: "a")...UInt8(ascii: "f"):
return ascii - UInt8(ascii: "a") + 10
case UInt8(ascii: "A")...UInt8(ascii: "F"):
return ascii - UInt8(ascii: "A") + 10
default:
return nil
}
}
public extension Data { public extension Data {
/// Lowercase hex representation of the bytes.
///
/// Lookup-table based: this sits on the hot BLE receive path (it is called
/// several times per received packet via `PeerID(hexData:)`), where the
/// previous per-byte `String(format: "%02x", _)` implementation spent most
/// of its time re-parsing the format string through Foundation.
func hexEncodedString() -> String { func hexEncodedString() -> String {
if self.isEmpty { if isEmpty {
return "" return ""
} }
return self.map { String(format: "%02x", $0) }.joined() var output = [UInt8](repeating: 0, count: count * 2)
var i = 0
for byte in self {
output[i] = hexDigits[Int(byte >> 4)]
output[i + 1] = hexDigits[Int(byte & 0x0F)]
i += 2
}
return String(decoding: output, as: UTF8.self)
} }
/// Initialize Data from a hex string. /// Initialize Data from a hex string.
@@ -28,28 +60,28 @@ public extension Data {
hex = String(hex.dropFirst(2)) hex = String(hex.dropFirst(2))
} }
let ascii = Array(hex.utf8)
// Reject odd-length strings // Reject odd-length strings
guard hex.count % 2 == 0 else { guard ascii.count % 2 == 0 else {
return nil return nil
} }
// Reject empty strings // Accept empty strings
guard !hex.isEmpty else { guard !ascii.isEmpty else {
self = Data() self = Data()
return return
} }
let len = hex.count / 2 var data = Data(capacity: ascii.count / 2)
var data = Data(capacity: len) var index = 0
var index = hex.startIndex while index < ascii.count {
guard let high = hexNibble(ascii[index]),
for _ in 0..<len { let low = hexNibble(ascii[index + 1]) else {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil return nil
} }
data.append(byte) data.append((high << 4) | low)
index = nextIndex index += 2
} }
self = data self = data
@@ -11,20 +11,17 @@ import struct Foundation.Date
public enum DeliveryStatus: Codable, Equatable, Hashable { public enum DeliveryStatus: Codable, Equatable, Hashable {
case sending case sending
case sent // Left our device 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 delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient case read(by: String, at: Date) // Seen by recipient
case failed(reason: String) case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms case partiallyDelivered(reached: Int, total: Int) // For rooms
public var displayText: String { public var displayText: String {
switch self { switch self {
case .sending: case .sending:
return "Sending..." return "Sending..."
case .sent: case .sent:
return "Sent" return "Sent"
case .carried:
return "Carried by a friend"
case .delivered(let nickname, _): case .delivered(let nickname, _):
return "Delivered to \(nickname)" return "Delivered to \(nickname)"
case .read(let nickname, _): case .read(let nickname, _):
@@ -12,9 +12,8 @@
public enum MessageType: UInt8 { public enum MessageType: UInt8 {
// Public messages (unencrypted) // Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving" 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) case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption // Noise encryption
@@ -30,7 +29,6 @@ public enum MessageType: UInt8 {
case .announce: return "announce" case .announce: return "announce"
case .message: return "message" case .message: return "message"
case .leave: return "leave" case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
case .requestSync: return "requestSync" case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake" case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted" case .noiseEncrypted: return "noiseEncrypted"
@@ -46,8 +46,7 @@ struct BinaryProtocolTests {
// Verify recipient // Verify recipient
#expect(decodedPacket.recipientID != nil) #expect(decodedPacket.recipientID != nil)
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes() let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates // TODO: Check if this is intended that the decoding only gets the first 8
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01") #expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
} }
@@ -295,7 +294,7 @@ struct BinaryProtocolTests {
@Test("Create a large, compressible payload above current threshold (2048B)") @Test("Create a large, compressible payload above current threshold (2048B)")
func payloadCompression() throws { func payloadCompression() throws {
let repeatedString = String(repeating: "This is a test message. ", count: 200) let repeatedString = String(repeating: "This is a test message. ", count: 200)
let largePayload = Data(repeatedString.utf8) let largePayload = repeatedString.data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: largePayload) let packet = TestHelpers.createTestPacket(payload: largePayload)
@@ -315,7 +314,7 @@ struct BinaryProtocolTests {
@Test("Small payloads should not be compressed") @Test("Small payloads should not be compressed")
func smallPayloadNoCompression() throws { func smallPayloadNoCompression() throws {
let smallPayload = Data("Hi".utf8) let smallPayload = "Hi".data(using: .utf8)!
let packet = TestHelpers.createTestPacket(payload: smallPayload) let packet = TestHelpers.createTestPacket(payload: smallPayload)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet") let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet") let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
@@ -363,7 +362,7 @@ struct BinaryProtocolTests {
var encodedSizes = Set<Int>() var encodedSizes = Set<Int>()
for payload in payloads { for payload in payloads {
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8)) let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet") 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) // Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
@@ -1,122 +0,0 @@
//
// 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))
}
}
@@ -0,0 +1,78 @@
//
// DataHexTests.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 DataHexTests {
// MARK: - Encoding
@Test func encode_knownVectors() {
#expect(Data().hexEncodedString() == "")
#expect(Data([0x00]).hexEncodedString() == "00")
#expect(Data([0x0f]).hexEncodedString() == "0f")
#expect(Data([0xf0]).hexEncodedString() == "f0")
#expect(Data([0xff]).hexEncodedString() == "ff")
#expect(Data([0xde, 0xad, 0xbe, 0xef]).hexEncodedString() == "deadbeef")
#expect(Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]).hexEncodedString() == "0123456789abcdef")
}
@Test func encode_allByteValues_matchesFormatReference() {
let all = Data((0...255).map { UInt8($0) })
let reference = (0...255).map { String(format: "%02x", $0) }.joined()
#expect(all.hexEncodedString() == reference)
}
@Test func encode_worksOnDataSlices() {
let data = Data([0xaa, 0xde, 0xad, 0xbe, 0xef, 0xbb])
let slice = data.dropFirst().dropLast()
#expect(slice.hexEncodedString() == "deadbeef")
}
// MARK: - Decoding
@Test func decode_knownVectors() {
#expect(Data(hexString: "deadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "DeAdBeEf") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "00") == Data([0x00]))
#expect(Data(hexString: "0123456789abcdef") == Data([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))
}
@Test func decode_handlesPrefixAndWhitespace() {
#expect(Data(hexString: "0xdeadbeef") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "0XDEADBEEF") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: " deadbeef\n") == Data([0xde, 0xad, 0xbe, 0xef]))
#expect(Data(hexString: "") == Data())
#expect(Data(hexString: "0x") == Data())
}
@Test func decode_rejectsInvalidInput() {
#expect(Data(hexString: "abc") == nil) // odd length
#expect(Data(hexString: "zz") == nil) // non-hex characters
#expect(Data(hexString: "0xg1") == nil) // non-hex after prefix
#expect(Data(hexString: "+f") == nil) // sign characters are not hex
#expect(Data(hexString: "-0") == nil)
#expect(Data(hexString: "a\u{00e9}") == nil) // non-ASCII
#expect(Data(hexString: "de ad") == nil) // interior whitespace
}
// MARK: - Round trip
@Test func roundTrip_randomLengths() {
for length in [0, 1, 2, 3, 8, 16, 31, 32, 33, 64, 255, 1024] {
let data = Data((0..<length).map { _ in UInt8.random(in: .min ... .max) })
let hex = data.hexEncodedString()
#expect(hex.count == length * 2)
#expect(Data(hexString: hex) == data)
#expect(Data(hexString: hex.uppercased()) == data)
}
}
}
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01, type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString), senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil, recipientID: PeerID? = nil,
payload: Data = Data("test payload".utf8), payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil, signature: Data? = nil,
ttl: UInt8 = 3 ttl: UInt8 = 3
) -> BitchatPacket { ) -> BitchatPacket {
return BitchatPacket( return BitchatPacket(
type: type, type: type,
senderID: Data(senderID.id.utf8), senderID: senderID.id.data(using: .utf8)!,
recipientID: recipientID?.id.data(using: .utf8), recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
+13 -86
View File
@@ -11,34 +11,16 @@
# never runner variance. Raise floors deliberately after intentional # never runner variance. Raise floors deliberately after intentional
# improvements; never tune them to chase noise. # 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] # 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: # Skips gracefully (exit 0) when:
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or # - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
# - the output contains no PERF lines (e.g. package-only matrix entries). # - the output contains no PERF lines (e.g. package-only matrix entries).
# #
# Fails when: # Fails (exit 1) when:
# - any benchmark reports throughput below its floor on every attempt # - any benchmark reports throughput below its floor, or
# (exit 1), or # - PERF lines are present but a floored benchmark is missing
# - PERF lines are present but a floored benchmark is missing — a # (a silently-dropped benchmark must be an explicit floors-file change).
# silently-dropped benchmark must be an explicit floors-file change and
# is not retried (exit 3).
set -euo pipefail set -euo pipefail
@@ -49,8 +31,6 @@ fi
OUTPUT_FILE="$1" OUTPUT_FILE="$1"
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}" 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 if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate." echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
@@ -72,17 +52,7 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
exit 0 exit 0
fi fi
# Absolute path so re-measurement appends to the same file regardless of the OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
# 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 json
import os import os
import re import re
@@ -102,20 +72,15 @@ with open(output_file, errors="replace") as f:
for line in f: for line in f:
m = pattern.search(line) m = pattern.search(line)
if m: if m:
# Keep the BEST reported value: measurement retries append to the # Keep the last reported value if a benchmark prints twice.
# same log, and a healthy benchmark only needs to clear its floor measured[m.group(1)] = (float(m.group(2)), m.group(3))
# 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)
below_floor = [] failures = []
missing = []
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)") print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
for name in sorted(set(floors) | set(measured)): for name in sorted(set(floors) | set(measured)):
floor = floors.get(name) floor = floors.get(name)
if name not in measured: if name not in measured:
missing.append( failures.append(
f" MISSING {name}: floored benchmark reported no PERF line " f" MISSING {name}: floored benchmark reported no PERF line "
f"(removed/renamed? update perf-floors.json in the same change)") f"(removed/renamed? update perf-floors.json in the same change)")
continue continue
@@ -127,18 +92,13 @@ for name in sorted(set(floors) | set(measured)):
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})" line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
print(line) print(line)
if value < floor: if value < floor:
below_floor.append( failures.append(
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} " f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
f"({value / floor * 100:.0f}% of floor)") f"({value / floor * 100:.0f}% of floor)")
if missing: if failures:
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:") print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
print("\n".join(missing + below_floor)) print("\n".join(failures))
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("\nFloors are ~25% of healthy local throughput; falling below one means an")
print("algorithmic regression, not runner noise. If the change is intentional,") print("algorithmic regression, not runner noise. If the change is intentional,")
print("update bitchatTests/Performance/perf-floors.json deliberately.") print("update bitchatTests/Performance/perf-floors.json deliberately.")
@@ -146,36 +106,3 @@ if below_floor:
print("perf-floors: all benchmarks at or above their floors.") print("perf-floors: all benchmarks at or above their floors.")
PYEOF 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