Compare commits

..
Author SHA1 Message Date
jackandClaude Fable 5 14ab04d2bb Correct doc comment on signing-key pin recovery
The comment on upsertCryptographicIdentity claimed a legitimately
re-keyed peer could recover via "explicit user re-verification", but no
such path exists: setVerified does not reset the signing-key pin. State
the actual recovery options — a new noise identity (new peerID) or
clearAllIdentityData.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 10:52:52 +02:00
jackandClaude Fable 5 c37e376568 Make identity-cache persistence synchronous to fix CI teardown hang
Root cause of the CI teardown wedge: SecureIdentityStateManager persisted
via fire-and-forget `queue.async(flags: .barrier)` in all ~10 mutating
methods (each doing encrypt + keychain write). This branch moved
`cryptographicIdentities` into the persisted IdentityCache, so the
announce/identity paths and their tests now schedule far more of these
async barrier saves than main ever did. Under `--enable-code-coverage`
(CI only) on the runner's constrained 2-3 cores, that backlog of
INSTRUMENTED fire-and-forget barrier work is still draining when LLVM
writes `.profraw` from its `atexit` handler; the dump deadlocks against
the in-flight instrumented threads -> all tests dispatch, ~5-min wedge,
Killed:9. main and the other PRs pass because they don't add this save
volume; it doesn't repro on an 18-core dev box because the backlog drains
before exit.

Fix (correct-by-construction, no CI-timing repro needed): convert every
mutating method's `queue.async(flags: .barrier)` to
`queue.sync(flags: .barrier)`. When a mutating API returns, the encrypt +
keychain write is already complete and NOTHING is scheduled on the queue,
so teardown/atexit has zero outstanding dispatch to wait on.

Re-entrancy audit (sync barriers deadlock if entered on-queue):
- No mutating method calls another mutating method (or forceSave) from
  inside a `queue` block — verified by grep; `saveIdentityCache` is a
  private inline helper, not a dispatch, so no nested self-hop exists.
- deinit remains queue-free (direct persist of in-hand state), so no
  mutating method is reachable from deinit.
- forceSave already uses queue.sync(.barrier) and is never called from
  deinit.
No `_onQueue` helper was needed.

Hot-path: upsertCryptographicIdentity runs on the BLE announce path (off
main, on the message/BLE queue) — a synchronous sub-millisecond keychain
write there is fine (announces are throttle/verified-gated). The other
mutating APIs (setFavorite/setBlocked/setVerified/updateSocialIdentity/
setNostrBlocked) are user-initiated one-shot UI/command actions; a fast
synchronous keychain write is acceptable and not in any hot loop.

Verified: no `queue.async` remains in code (comments only);
`time swift test --parallel --enable-code-coverage --skip
PerformanceBaselineTests` green and exits ~3s after the last test across
repeated runs, including under LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 with
2 workers (~8s, no wedge); ThreadSanitizer clean on the identity +
announce suites; canonical `swift build && swift test --parallel` green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:26:57 +02:00
jackandClaude Fable 5 51186c3be6 Remove identity concurrency stress tests that wedged CI at teardown
The two stress tests I added (test_concurrentUpsertsAndForceSaveDoNotRaceOrHang
and test_manyManagersDeinitDoNotWedgeTeardown) spawned units of work that the
test could not deterministically join before returning:

- test_manyManagersDeinitDoNotWedgeTeardown created 200 managers that each
  scheduled a fire-and-forget queue.async(.barrier) save on the manager's own
  private queue; the test has no handle to await that per-manager barrier work,
  so a large backlog of it could still be executing after the test returned.
- test_concurrentUpsertsAndForceSaveDoNotRaceOrHang spawned 32
  DispatchQueue.global().async workers, each also scheduling per-manager
  barrier saves; the DispatchGroup only joined the worker loops, not the
  manager's internal barrier work.

Under --enable-code-coverage (CI only), LLVM writes .profraw from an atexit
handler; if instrumented worker threads are still live during that dump the
process deadlocks at exit — matching the CI signature exactly: all 145 tests
start, then a ~5-minute wedge, then Killed:9. It reproduced only on the
constrained CI runner, not locally (18 cores drained the backlog before exit),
which is why earlier local runs looked clean.

The production fix is already verified: ThreadSanitizer is clean on the
identity + announce-handler suites (no data race, no re-entrant deadlock), and
the deterministic unit tests cover the signing-key pin refusal, persistence
across re-init, and the persisted-pin fallback. A stress test that
destabilizes CI is worse than no stress test, so both are removed along with
the now-unused LockedKeychain double.

Verified: `time swift test --parallel --enable-code-coverage
--skip PerformanceBaselineTests` is green 6x and the process exits ~2.9s after
the last test (tests run in ~1.5s); no teardown wedge under coverage even with
LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 and a single worker.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 01:10:47 +02:00
jackandClaude Fable 5 8df3871096 Remove stray coverage artifact and ignore *.profraw
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:54:13 +02:00
jackandClaude Fable 5 a8b741d605 Stop identity-manager deinit from dispatching onto its own queue
The previous fix routed forceSave() through queue.async(flags: .barrier),
and deinit called forceSave(). Dispatching onto the private concurrent
queue from deinit is a teardown hazard: the async block resurrects self
and enqueues barrier work that may not drain before process exit, so at
teardown swift-testing/libdispatch waits on that outstanding work and the
process never exits — the CI "Run Swift Tests (app)" job reached
[144/144], then wedged for ~5 minutes and was Killed:9. This surfaced now
because moving cryptographicIdentities into the persisted cache made far
more test-created managers persist on deinit.

Root cause confirmed locally: under LIBDISPATCH_COOPERATIVE_POOL_STRICT=1
(single-worker pool, mimicking a constrained CI runner) the old code
intermittently stalled the whole suite ~15s from deinit-scheduled barrier
work starving the pool; the fix is stable across 14 full coverage+parallel
runs with the process exiting ~3s after the last test.

Fix:
- deinit no longer touches `queue`. Persistence is already durable (every
  mutating API persists inline within its own barrier), so deinit only
  does a queue-free best-effort flush if `pendingSave` is still set — a
  direct read of in-hand state, safe because a deallocating object has no
  other live references mutating `cache`.
- forceSave() (lifecycle/app-termination only, never deinit) now uses a
  synchronous queue.sync(flags: .barrier): race-free `cache` read on the
  barrier context and nothing left scheduled at teardown. No re-entrant
  deadlock risk since it is never called from deinit.

Test: test_manyManagersDeinitDoNotWedgeTeardown churns 200 managers that
mutate then deinit and asserts the workload completes promptly; combined
with the existing concurrent race guard (still TSan-clean) this covers the
deinit path. Verified: swift test --parallel --enable-code-coverage green
14x with prompt process exit, and TSan-clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:54:02 +02:00
jackandClaude Fable 5 a55a16adcd Fix identity-cache save race and deinit deadlock in forceSave
The previous commit moved cryptographicIdentities into the persisted
IdentityCache, which made the pre-existing off-queue cache access in
the save path fatal instead of merely racy: forceSave() -> performSave()
read and JSON-encoded `cache` on the caller's thread while a concurrent
`queue.async(.barrier)` writer mutated the same dictionary. ThreadSanitizer
flags this as a data race in saveIdentityCache(), and because JSONEncoder
walks the dictionary storage, an interleaved mutation can spin forever —
which is what hung the CI "Run Swift Tests (app)" job (killed at the
watchdog timeout).

The naive fix (snapshot `cache` under `queue.sync` in forceSave) instead
introduced a deadlock: forceSave() is reachable from deinit, and the
object's final release can run *on* the identity queue (the fire-and-forget
barrier saves capture self), so queue.sync there is a re-entrant same-queue
wait -> SIGTRAP. That matched the intermittent crash the hang investigation
surfaced.

Fix:
- Split persistence into persist(snapshot:) which encodes/seals/writes a
  by-value IdentityCache snapshot, decoupled from reading `cache`.
- saveIdentityCache() (only ever called inside a barrier writer) passes
  `cache` directly — already serialized, race-free.
- forceSave() now snapshots + persists inside `queue.async(flags:.barrier)`
  (async, never sync): the read is on the barrier context so it never races
  an in-flight write, and being async it can't deadlock when invoked from
  deinit running on the queue. Durability is unaffected: every mutating API
  already persists inline within its own barrier, so forceSave is a
  belt-and-suspenders flush.

Test: test_concurrentUpsertsAndForceSaveDoNotRaceOrHang hammers concurrent
upserts interleaved with forceSave; it reproduces the data race under
`--sanitize=thread` on the old code (SecureIdentityStateManager.swift:250)
and passes cleanly with the fix. A lock-guarded LockedKeychain double is
used so the test exercises the manager's own cache race rather than the
non-thread-safe MockKeychain. Verified green over repeated
`swift test --parallel` runs both with and without --enable-code-coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:25:14 +02:00
jackandClaude Fable 5 dbe11641ad Extend signing-key pin to persisted identity so it survives restart/eviction
The TOFU signing-key pin lived only in the in-memory peerRegistry. When
a previously seen peer was no longer in the registry (app restart, or
reconcileConnectivity pruning an offline peer), the announce trust check
saw no pinned key, treated the announce as first contact, and
persistIdentity overwrote the cached signing key/nickname — so an
attacker could replay a victim's noiseKey/peerID with their own signing
key and bypass the spoofing protection for returning/offline peers.

Fixes:

- BLEAnnounceHandler now falls back to the persisted cryptographic
  identity (via a new persistedSigningPublicKey environment closure)
  when the registry has no signing key for the peer, so the pin remains
  effective across registry eviction and app restarts. BLEService wires
  it to SecureIdentityStateManager.getCryptoIdentitiesByPeerIDPrefix,
  the same synchronous identity-manager read already used on the packet
  path by signedSenderDisplayName.
- SecureIdentityStateManager.upsertCryptographicIdentity refuses to
  replace a persisted signing key with a different one (security-logged,
  nickname update included in the refusal), mirroring the registry
  policy. First-writer-wins persistence also removes any race where a
  concurrent announce could poison the stored identity.
- CryptographicIdentity entries (incl. the signing-key pin) are now part
  of the encrypted IdentityCache, so they actually persist across app
  restarts; previously they were in-memory only. Old caches without the
  new field still decode (decodeIfPresent), and clearAllIdentityData /
  panic wipe clears the pins as before.

Legitimate re-keying: presenting a different signing key for the same
noise key is exactly the attack being blocked, so refusal is correct
and permanent until the peer adopts a new noise identity (new peerID)
or the user explicitly clears identity data. Repeated rejected announces
follow the existing unverified-announce path (log + ignore); no retry
loops or crashes.

Tests: handler-level persisted-pin mismatch/match/precedence cases, an
end-to-end restart+eviction test with real Ed25519 keys and a real
SecureIdentityStateManager showing the attacker replay is rejected and
the persisted identity is untouched while the victim re-announce is
accepted, and identity-manager tests for the pinned-key refusal and the
keychain round-trip of the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:55:40 +02:00
jackandGitHub ccf17326d2 Merge branch 'main' into fix/announce-signing-key-binding 2026-07-01 23:52:35 +02:00
jackandClaude Fable 5 385d32063f Pin announce signing keys to stop mesh identity spoofing
BLE announce trust verified the packet signature against the Ed25519
signing key carried inside the same announce, and the trust policy only
rejected on noise-key mismatch. Since peerIDs derive from the broadcast
(public) noise key, an on-mesh attacker could replay a victim's
peerID+noiseKey with their own signing key, nickname, and a valid
self-signature; BLEPeerRegistry.upsertVerifiedAnnounce then overwrote
the victim's entry unconditionally. That enabled mesh nickname spoofing
and, via the persisted identity, forged attribution of signed
public/broadcast messages.

Fix: TOFU-pin the signing key per peer (noise-key-derived peerID).

- BLEAnnounceTrustPolicy now rejects announces whose signing key
  differs from the one already recorded for the peer
  (.signingKeyMismatch) and logs a security event.
- BLEPeerRegistry.upsertVerifiedAnnounce refuses to replace a pinned
  signing key (returns nil), closing the race where the pre-barrier
  trust check reads the registry outside the collections barrier. It
  also never drops a pinned key when an announce omits one.
- BLEAnnounceHandler skips registry upsert, topology updates, and
  identity persistence for rejected announces.

No wire-format change: the packet signature already covers senderID,
timestamp, and the full announce payload (noise key, signing key,
nickname), so mixed-version meshes are unaffected. First contact for an
unknown peer behaves exactly as before (trust on first use); the pin is
scoped to the registry entry lifetime, so a legitimately re-keyed
identity recovers after normal peer eviction.

Tests: trust-policy signing-key mismatch/match cases, registry pinning
(attacker upsert refused, legitimate re-announce accepted, omitted key
keeps pin), handler-level pinned-key rejection, and an end-to-end test
with real Ed25519 keys showing a fully self-consistent attacker
announce cannot displace the victim's pinned identity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:31:09 +02:00
107 changed files with 1736 additions and 5183 deletions
+2 -34
View File
@@ -12,10 +12,7 @@ jobs:
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
# The long steps carry tighter individual bounds (5-minute test watchdog,
# 6-minute benchmark step, 10-minute floor gate that may re-run the
# benchmarks up to twice on a noisy runner); this is the backstop.
timeout-minutes: 25
timeout-minutes: 15
strategy:
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
# generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance. If a metric
# still lands below floor (a saturated runner can dip one), the script
# re-runs the benchmarks — appending to the same log and keeping each
# benchmark's best value per metric — so noise clears on retry while a
# real regression fails every attempt. Floors are never lowered by this.
# catches algorithmic regressions, never runner variance.
- name: Performance floor gate
if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
@@ -153,27 +145,3 @@ jobs:
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
build
# Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge.
lint:
name: SwiftLint (advisory)
runs-on: ubuntu-latest
timeout-minutes: 15
# This job runs a third-party container image, so give it the least
# privilege we can: a read-only token, and no credentials left in the
# checkout for the container to find.
permissions:
contents: read
container:
# Tag for readability, digest for immutability (tags can be repointed).
# Bump both together, deliberately — never a floating tag.
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
continue-on-error: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Run SwiftLint
run: swiftlint lint --reporter github-actions-logging
+1
View File
@@ -80,3 +80,4 @@ build.log
# Local configs
Local.xcconfig
*.profraw
-33
View File
@@ -1,33 +0,0 @@
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
# (CI checkouts are fresh, so this only matters in a working tree).
excluded:
- .build
- .swiftpm
- .DerivedData
- DerivedData
- build
- localPackages/*/.build
disabled_rules:
- line_length
- type_name
- identifier_name
- statement_position
- implicit_optional_initialization
- force_try
- vertical_whitespace
- for_where
- control_statement
- void_function_in_ternary
- redundant_discardable_let # SwiftUI breaks without it
# To be enabled as we fix the issues
- trailing_whitespace
- cyclomatic_complexity
- function_body_length
- function_parameter_count
- type_body_length
- file_length
- large_tuple
- force_cast
- multiple_closures_with_trailing_closure
- nesting
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.4
MARKETING_VERSION = 1.5.3
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+2 -2
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable(
name: "bitchat",
targets: ["bitchat"]
)
),
],
dependencies: [
dependencies:[
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
+4 -4
View File
@@ -561,7 +561,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.4;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -620,7 +620,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.4;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -655,7 +655,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.4;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -749,7 +749,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.4;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
-20
View File
@@ -49,14 +49,6 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendMessage(message)
}
/// Resends a failed private message through the normal send path,
/// removing the failed original so the re-submission replaces it
/// instead of stacking a duplicate under the red bubble.
func resendFailedPrivateMessage(_ message: BitchatMessage) {
chatViewModel.removePrivateMessage(withID: message.id)
chatViewModel.sendMessage(message.content)
}
func clearCurrentConversation() {
chatViewModel.sendMessage("/clear")
}
@@ -75,23 +67,11 @@ final class ConversationUIModel: ObservableObject {
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
// Mesh: block the peer's stable Noise identity resolved from the
// tapped peerID rather than re-resolving a display-name string.
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
} else {
chatViewModel.sendMessage("/block \(displayName)")
}
}
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
/// the tapped peer's stable identity so the exact row is unblocked this
/// also works for offline peers, which the `/unblock <displayName>` command
/// cannot resolve.
func unblock(peerID: PeerID, displayName: String) {
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
+1 -7
View File
@@ -232,13 +232,7 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
let availability = resolveAvailability(for: headerPeerID, peer: peer)
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
+1 -1
View File
@@ -71,7 +71,7 @@ struct BitchatApp: App {
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
true
}
+32 -10
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
}
enum TrustLevel: String, Codable {
case unknown
case casual
case trusted
case verified
case unknown = "unknown"
case casual = "casual"
case trusted = "trusted"
case verified = "verified"
}
// MARK: - Identity Cache
@@ -141,22 +141,44 @@ enum TrustLevel: String, Codable {
struct IdentityCache: Codable {
// Fingerprint -> Social mapping
var socialIdentities: [String: SocialIdentity] = [:]
// Nickname -> [Fingerprints] reverse index
// Multiple fingerprints can claim same nickname
var nicknameIndex: [String: Set<String>] = [:]
// Verified fingerprints (cryptographic proof)
var verifiedFingerprints: Set<String> = []
// Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:]
var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
// Persisting the signing-key pin is security-critical: it must survive
// app restarts so an attacker cannot replay a known peer's
// noiseKey/peerID with their own signing key and be treated as first
// contact (TOFU downgrade).
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Schema version for future migrations
var version: Int = 1
init() {}
// Custom decoding so caches written by older builds (without
// `cryptographicIdentities`) still load instead of being discarded.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
}
//
+104 -43
View File
@@ -145,18 +145,29 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache()
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// keeps the dispatch machinery alive and prevents the unit-test process from
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// no run loop, so saves never actually fired.)
//
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
// returns the write is already complete and NOTHING is left scheduled on
// the queue. This is deliberate a retained DispatchSourceTimer (the
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false
// Encryption key
@@ -216,7 +227,22 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
deinit {
forceSave()
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
}
// MARK: - Secure Loading/Saving
@@ -241,21 +267,27 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// and persists it on the same serialized context no timer, nothing left
/// scheduled to keep the process alive.
/// Persists the cache. Always invoked on `queue` under a barrier (its
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
/// while serialized. The encode + keychain write are done here (already on
/// the exclusive barrier context), synchronously, so no separate hop is
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() {
pendingSave = true
performSave()
// On the barrier context already: snapshot is trivially consistent.
persist(snapshot: cache)
pendingSave = false
}
/// Writes the cache to the keychain. Must run on `queue` with exclusive
/// (barrier) access.
private func performSave() {
guard pendingSave else { return }
pendingSave = false
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
///
/// Takes the cache by value so callers can capture a consistent snapshot
/// under `queue` and then encode without holding it. Reading `cache`
/// concurrently with a barrier writer would be a data race on the
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
@@ -264,7 +296,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
do {
let data = try JSONEncoder().encode(cache)
let data = try JSONEncoder().encode(snapshot)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved {
@@ -275,14 +307,26 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
// Force immediate save (for app termination / lifecycle events). Mutations
// already persist synchronously via saveIdentityCache, so this is normally a
// no-op (performSave early-returns when nothing is pending). Runs directly on
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// reachable from `deinit` and from async tests on the swift-concurrency
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
// Force a flush (for app-termination / lifecycle events NOT from
// `deinit`, which persists inline; see the deinit note). Every mutating
// API already persists inline inside its own barrier via
// `saveIdentityCache`, so by the time this is called the keychain is
// already up to date and this is normally a no-op; it exists as a
// belt-and-suspenders flush of any `pendingSave` left set.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() {
performSave()
queue.sync(flags: .barrier) {
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
}
// MARK: - Social Identity Management
@@ -296,15 +340,33 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
let now = Date()
if var existing = self.cryptographicIdentities[fingerprint] {
if var existing = self.cache.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed
if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity(
@@ -314,7 +376,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = existing
self.cache.cryptographicIdentities[fingerprint] = existing
} else {
// Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
@@ -325,7 +387,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
self.cache.cryptographicIdentities[fingerprint] = updated
}
// Persist updated state (already assigned in branches above)
} else {
@@ -337,7 +399,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
firstSeen: now,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = entry
self.cache.cryptographicIdentities[fingerprint] = entry
}
// Optionally persist claimed nickname into social identity
@@ -369,12 +431,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync {
// Defensive: ensure hex and correct length
guard peerID.isShort else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity
@@ -410,7 +472,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity
@@ -448,7 +510,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked
if isBlocked {
@@ -482,7 +544,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased()
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if isBlocked {
self.cache.blockedNostrPubkeys.insert(key)
} else {
@@ -499,7 +561,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
@@ -509,7 +571,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction
@@ -525,11 +587,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.cache = IdentityCache()
self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
@@ -537,7 +598,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
func removeEphemeralSession(peerID: PeerID) {
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID)
}
}
@@ -547,7 +608,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.async(flags: .barrier) {
queue.sync(flags: .barrier) {
if verified {
self.cache.verifiedFingerprints.insert(fingerprint)
} else {
File diff suppressed because it is too large Load Diff
+12 -19
View File
@@ -11,38 +11,33 @@ import Foundation
// MARK: - CommandInfo Enum
enum CommandInfo: String, Identifiable {
// Raw values must match the aliases CommandProcessor actually accepts
// the suggestion panel is the app's only command-discovery surface, and
// suggesting a spelling the processor rejects teaches users dead ends.
case block
case clear
case help
case hug
case message = "msg"
case message = "dm"
case slap
case unblock
case who
case favorite = "fav"
case unfavorite = "unfav"
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .help, .who:
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
@@ -52,14 +47,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
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 IK // Initiator knows responder's static key
case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
}
enum NoiseRole {
@@ -602,7 +601,7 @@ final class NoiseHandshakeState {
switch pattern {
case .XX:
break // No pre-message keys
case .IK, .NK, .X:
case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
@@ -905,7 +904,6 @@ extension NoisePattern {
case .XX: return "XX"
case .IK: return "IK"
case .NK: return "NK"
case .X: return "X"
}
}
@@ -927,10 +925,6 @@ extension NoisePattern {
[.e, .es], // -> e, es
[.e, .ee] // <- e, ee
]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
}
}
}
+1 -1
View File
@@ -648,7 +648,7 @@ private extension NostrProtocol {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: Data("nip44-v2".utf8),
info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
-7
View File
@@ -137,10 +137,6 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false
@@ -1091,9 +1087,6 @@ final class NostrRelayManager: ObservableObject {
private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct
// set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
}
/// A relay that drops before sending EOSE must not stall initial-load
@@ -132,3 +132,4 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5
case .province: return 4
case .region: return 2
}
}
}
var displayName: String {
+46 -31
View File
@@ -14,8 +14,14 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Noise and signing public keys already recorded for the peer, if any
/// (single registry read so both come from one consistent snapshot).
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (PeerID) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
@@ -23,13 +29,15 @@ struct BLEAnnounceHandlerEnvironment {
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: (
_ peerID: PeerID,
_ announcement: AnnouncementPacket,
_ isConnected: Bool,
_ now: Date
) -> BLEPeerAnnounceUpdate
) -> BLEPeerAnnounceUpdate?
/// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -59,15 +67,6 @@ struct BLEAnnounceHandlerEnvironment {
let scheduleAfterglow: (TimeInterval) -> Void
}
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response.
@@ -78,8 +77,7 @@ final class BLEAnnounceHandler {
self.environment = environment
}
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
@@ -95,21 +93,30 @@ final class BLEAnnounceHandler {
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return nil
return
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return nil
return
case .reject(.selfAnnounce):
return nil
return
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return nil
return
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
var existingPeerKeys = env.existingPeerKeys(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
@@ -123,13 +130,18 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey,
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
announcedSigningPublicKey: announcement.signingPublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
if case .reject(.signingKeyMismatch) = trustDecision {
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
}
var verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
@@ -149,12 +161,22 @@ final class BLEAnnounceHandler {
return
}
let update = env.upsertVerifiedAnnounce(
// The registry re-checks the signing-key pin inside the barrier.
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now
)
) else {
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
@@ -220,12 +242,5 @@ final class BLEAnnounceHandler {
let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay)
}
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
}
}
@@ -56,6 +56,7 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
case signingKeyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
@@ -72,12 +73,25 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
announcedNoisePublicKey: Data,
existingSigningPublicKey: Data?,
announcedSigningPublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
+6 -64
View File
@@ -19,35 +19,13 @@ enum BLEFanoutSelector {
packetType: UInt8,
messageID: String
) -> 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(
rawAllowed,
allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
@@ -93,42 +71,6 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
return false
}
}
+18 -2
View File
@@ -150,6 +150,14 @@ struct BLEPeerRegistry {
peers[peerID] = peer
}
/// Applies a verified announce to the registry.
///
/// TOFU signing-key pinning: once a signing key has been bound to this
/// peer entry, an announce carrying a *different* signing key is refused
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
/// derived from the (public) noise key, so without pinning an attacker
/// could replay a victim's noiseKey/peerID with their own signing key and
/// silently take over the victim's mesh identity and nickname.
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
@@ -157,8 +165,15 @@ struct BLEPeerRegistry {
signingPublicKey: Data?,
isConnected: Bool,
now: Date
) -> BLEPeerAnnounceUpdate {
) -> BLEPeerAnnounceUpdate? {
let existing = peers[peerID]
if let pinnedSigningKey = existing?.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
return nil
}
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
@@ -170,7 +185,8 @@ struct BLEPeerRegistry {
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
// Never drop an already-pinned signing key.
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
isVerifiedNickname: true,
lastSeen: now
)
+28 -190
View File
@@ -46,20 +46,6 @@ final class BLEService: NSObject {
// 4. Efficient Message Deduplication
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable
// for tests; main-actor policy because favorites live on the main actor.
var courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
}
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
// packets between in-process service instances.
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -902,10 +888,6 @@ final class BLEService: NSObject {
} else {
packetToSend = packet
}
#if DEBUG
_test_onOutboundPacket?(packetToSend)
#endif
// Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
@@ -1065,9 +1047,6 @@ final class BLEService: NSObject {
// Directed send helper (unicast to a specific peerID) without altering packet contents
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
#if DEBUG
_test_onOutboundPacket?(packet)
#endif
guard let data = packet.toBinaryData(padding: false) else { return }
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
}
@@ -1326,7 +1305,7 @@ extension BLEService: GossipSyncManager.Delegate {
extension BLEService: CBCentralManagerDelegate {
#if os(iOS)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
@@ -2008,7 +1987,7 @@ extension BLEService: CBPeripheralManagerDelegate {
}
#if os(iOS)
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
@@ -2489,147 +2468,7 @@ extension BLEService {
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
}
// A present sender resolves to their live mesh thread via the
// derived short ID. An absent sender the usual courier case
// uses the full noise-key ID so the message lands on the stable
// favorite conversation instead of an unresolvable short-ID
// thread labeled "Unknown".
let shortID = PeerID(publicKey: senderStaticKey)
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
type: payloadType,
payload: payload,
timestamp: Date()
))
}
} catch {
// Tag collision or stale key: not addressed to us after all.
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
}
}
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
let store = courierStore
let policy = courierDepositPolicy
Task { @MainActor in
guard policy(depositorKey) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
return
}
if store.deposit(envelope, from: depositorKey) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session)
}
}
}
/// Hand over any carried envelopes addressed to a peer we just heard from.
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
guard !envelopes.isEmpty else { return }
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))", category: .session)
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
}
}
// MARK: Link capability snapshots (thread-safe via bleQueue)
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
@@ -2762,11 +2601,8 @@ extension BLEService {
// MARK: Private Message Handling
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
let recipientID = recipientID.toShort()
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
// Check if we have an established Noise session
if noiseService.hasEstablishedSession(with: recipientID) {
// Encrypt and send
@@ -2864,7 +2700,7 @@ extension BLEService {
// Notify delegate of failure
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")))
}
}
}
@@ -3117,15 +2953,13 @@ extension BLEService {
case .fileTransfer:
handleFileTransfer(packet, from: senderID)
case .courierEnvelope:
handleCourierEnvelope(packet, from: peerID)
case .leave:
handleLeave(packet, from: senderID)
case .none:
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
break
}
if forwardAlongRouteIfNeeded(packet) {
@@ -3182,18 +3016,7 @@ extension BLEService {
}
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise
// static key, so check whether we're carrying mail addressed to them.
// Direct announces only: envelopes are removed from the store
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
guard !courierStore.isEmpty,
let result,
result.isVerified,
result.isDirectAnnounce else { return }
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
announceHandler.handle(packet, from: peerID)
}
/// Builds the announce handler environment. All queue hops stay here so
@@ -3205,9 +3028,21 @@ extension BLEService {
},
messageTTL: messageTTL,
now: { Date() },
existingNoisePublicKey: { [weak self] peerID in
existingPeerKeys: { [weak self] peerID in
guard let self = self else { return (nil, nil) }
return self.collectionsQueue.sync {
let info = self.peerRegistry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
}
},
persistedSigningPublicKey: { [weak self] peerID in
// Same synchronous identity-manager read pattern as
// signedSenderDisplayName(for:from:); the manager serializes
// access on its own internal queue.
guard let self = self else { return nil }
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { [weak self] packet, signingPublicKey in
self?.noiseService.verifyPacketSignature(packet, publicKey: signingPublicKey) ?? false
@@ -3220,14 +3055,17 @@ extension BLEService {
},
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly.
self?.peerRegistry.upsertVerifiedAnnounce(
guard let self = self else {
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}
return self.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
)
},
shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly.
+25 -40
View File
@@ -51,9 +51,8 @@ protocol CommandContextProvider: AnyObject {
func addPublicSystemMessage(_ content: String)
// MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
}
/// Processes chat commands in a focused, efficient way
@@ -106,28 +105,11 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default:
return .error(message: "unknown command: \(cmd) — 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
private func handleMessage(_ args: String) -> CommandResult {
@@ -336,31 +318,34 @@ final class CommandProcessor {
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)")
}
// Resolve current state by the peer's real noise key. The resolved
// peerID is either the short 16-hex mesh ID or the full 64-hex
// noise-key ID (offline favorite row) never the noise key itself.
let isCurrentlyFavorite: Bool
if let noiseKey = peerID.noiseKey {
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
if add {
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
peerNickname: nickname
)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else {
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
}
guard add != isCurrentlyFavorite else {
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
}
// toggleFavorite persists by the real noise key and notifies the peer.
contextProvider?.toggleFavorite(peerID: peerID)
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
}
}
-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")
}
}
@@ -141,13 +141,7 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil
) {
let existing = favorites[peerNoisePublicKey]
// Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
// never let it clobber a real stored nickname.
let incoming = peerNickname.flatMap { name in
(name.isEmpty || name == "Unknown") ? nil : name
}
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
+2 -77
View File
@@ -2,37 +2,11 @@ import BitLogger
import BitFoundation
import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) may carry our mail.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
// Offline favorites are addressed by the full 64-hex
// noise-key ID, which carries the key itself; the favorites
// lookup only resolves short 16-hex IDs.
peerID.noiseKey
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -40,12 +14,6 @@ final class MessageRouter {
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically
/// carry the sealed envelope). Delivery stays best-effort: the outbox
/// retains the message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
@@ -63,17 +31,10 @@ final class MessageRouter {
// Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil
) {
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -90,7 +51,7 @@ final class MessageRouter {
}
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
note.userInfo?["isKeyUpdate"] is Bool {
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerID(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
@@ -128,47 +89,11 @@ final class MessageRouter {
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID)
// "Reachable" without prompt delivery means the send only joined
// a queue (Nostr with relays down): also hand a sealed copy to
// any connected couriers rather than waiting for internet that
// may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
} else {
var unsent = message
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
}
}
/// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: 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? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
}
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
@@ -471,7 +428,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data()
// 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(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -487,7 +444,7 @@ final class NoiseEncryptionService {
out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes
let nickData = Data(nickname.utf8)
let nickData = nickname.data(using: .utf8) ?? Data()
out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255))
// timestamp
+9 -32
View File
@@ -14,13 +14,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
/// still queue.
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
@MainActor
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies(
notificationCenter: .default,
@@ -32,8 +26,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
}
)
}
}
@@ -56,10 +49,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
// Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = []
// Mirror of the relay manager's connection state, cached here because
// canDeliverPromptly is called synchronously off the main actor.
private var relaysConnected = false
private var relayConnectivityCancellable: AnyCancellable?
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor
@@ -83,12 +72,6 @@ final class NostrTransport: Transport, @unchecked Sendable {
queue.sync(flags: .barrier) {
self.reachablePeers = Set(reachable)
}
relayConnectivityCancellable = self.dependencies.relayConnectivity()
.sink { [weak self] connected in
guard let self else { return }
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
}
}
deinit {
@@ -142,25 +125,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
func isPeerReachable(_ peerID: PeerID) -> Bool {
// Callers address peers by either the short 16-hex ID or the full
// 64-hex noise key (offline favorites), so compare in short form.
let short = peerID.toShort()
return queue.sync {
queue.sync {
// Check if exact match
if reachablePeers.contains(peerID) { return true }
return reachablePeers.contains(where: { $0.toShort() == short })
// Check for short ID match
if peerID.isShort {
return reachablePeers.contains(where: { $0.toShort() == peerID })
}
return false
}
}
func canDeliverPromptly(to peerID: PeerID) -> Bool {
// A known npub makes a peer "reachable", but with no relay
// connection a send only joins the local queue. Answering honestly
// here lets the router hand a sealed copy to a courier in parallel
// instead of waiting for internet that may never come.
isPeerReachable(peerID) && queue.sync { relaysConnected }
}
func peerNickname(peerID: PeerID) -> String? { nil }
func getPeerNicknames() -> [PeerID: String] { [:] }
func getPeerNicknames() -> [PeerID : String] { [:] }
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
+1 -1
View File
@@ -111,7 +111,7 @@ final class NotificationService {
func requestAuthorization() {
guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted {
// Permission granted
} else {
+1 -1
View File
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
case .read, .delivered:
externalReceipts.insert(message.id)
sentReadReceipts.insert(message.id)
case .failed, .partiallyDelivered, .sending, .sent, .carried:
case .failed, .partiallyDelivered, .sending, .sent:
break
}
}
-16
View File
@@ -54,11 +54,6 @@ protocol Transport: AnyObject {
// Connectivity and peers
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
/// Whether a send to this peer is likely to leave the device promptly.
/// Distinct from reachability: Nostr claims any favorite with a known
/// npub as reachable even with no relay connection, where a send only
/// joins a queue waiting for internet that may never come.
func canDeliverPromptly(to peerID: PeerID) -> Bool
func peerNickname(peerID: PeerID) -> String?
func getPeerNicknames() -> [PeerID: String]
@@ -100,12 +95,6 @@ protocol Transport: AnyObject {
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func cancelTransfer(_ transferId: String)
// Courier store-and-forward (mesh transports only): seal a message to the
// recipient's static key and hand it to connected couriers for physical
// delivery while the recipient is offline. Returns false when the
// transport cannot courier (no connected courier, or unsupported).
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -116,10 +105,6 @@ protocol Transport: AnyObject {
}
extension Transport {
// Reachability implies prompt delivery for transports that hand packets
// straight to the radio; queue-backed transports override this.
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
// Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
@@ -135,7 +120,6 @@ extension Transport {
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
func cancelTransfer(_ transferId: String) {}
+17 -38
View File
@@ -86,44 +86,45 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
var enrichedPeers: [BitchatPeer] = []
var connected: Set<PeerID> = []
var addedPeerIDs: Set<PeerID> = []
var meshNoiseKeys: Set<Data> = []
// Phase 1: Add all mesh peers (connected and reachable)
for peerInfo in meshPeers {
let peerID = peerInfo.peerID
guard peerID != meshService.myPeerID else { continue } // Never add self
let peer = buildPeerFromMesh(
peerInfo: peerInfo,
favorites: favorites,
meshAttached: hasAnyConnected
)
enrichedPeers.append(peer)
if peer.isConnected { connected.insert(peerID) }
addedPeerIDs.insert(peerID)
// Update fingerprint cache
if let publicKey = peerInfo.noisePublicKey {
meshNoiseKeys.insert(publicKey)
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
}
}
// Phase 2: Add offline favorites that we actively favorite.
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
// the full 32-byte noise key, so dedup must compare noise keys a
// PeerID comparison between the two forms can never match.
// Phase 2: Add offline favorites that we actively favorite
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
if meshNoiseKeys.contains(favoriteKey) { continue }
let peerID = PeerID(hexData: favoriteKey)
// Skip if already added (connected peer)
if addedPeerIDs.contains(peerID) { continue }
// Skip if connected under different ID but same nickname
let isConnectedByNickname = enrichedPeers.contains {
$0.nickname == favorite.peerNickname && $0.isConnected
}
if isConnectedByNickname { continue }
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
enrichedPeers.append(peer)
addedPeerIDs.insert(peerID)
// Update fingerprint cache
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
}
@@ -256,29 +257,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
return false
}
/// Block or unblock a mesh peer by its stable Noise identity.
///
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
/// (cache / mesh session / known-peer Noise key). This works even when the
/// peer is offline including offline favorites so the exact tapped peer
/// is (un)blocked unambiguously instead of being re-resolved by a
/// display-name string that two peers could share.
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
@discardableResult
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
guard let fingerprint = getFingerprint(for: peerID) else {
SecureLogger.warning(
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
category: .session
)
return nil
}
identityManager.setBlocked(fingerprint, isBlocked: blocked)
updatePeers()
return fingerprint
}
/// Toggle favorite status
func toggleFavorite(_ peerID: PeerID) {
guard let peer = getPeer(by: peerID) else {
-3
View File
@@ -20,9 +20,6 @@ struct SyncTypeFlags: OptionSet {
case .fragment: return 5
case .requestSync: return 6
case .fileTransfer: return 7
// Courier envelopes are directed deposits between trusted peers and
// must never spread via gossip sync.
case .courierEnvelope: return nil
}
}
@@ -27,3 +27,4 @@ struct PeerDisplayNameResolver {
return result
}
}
@@ -355,10 +355,9 @@ private extension ChatLifecycleCoordinator {
case .failed: return 1
case .sending: return 2
case .sent: return 3
case .carried: return 4
case .partiallyDelivered: return 5
case .delivered: return 6
case .read: return 7
case .partiallyDelivered: return 4
case .delivered: return 5
case .read: return 6
}
}
}
@@ -117,13 +117,13 @@ final class ChatMediaTransferCoordinator {
try? FileManager.default.removeItem(at: url)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: 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 {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: 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")
}
}
}
+58 -3
View File
@@ -21,9 +21,13 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Favorites (shared with the other contexts)
// MARK: Favorites & notifications (shared with the other contexts)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
}
extension ChatViewModel: ChatNostrContext {
@@ -67,7 +71,7 @@ final class ChatNostrCoordinator {
key: Data?
) {
guard let context else { return }
if key != nil {
if let _ = key {
if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
@@ -80,7 +84,7 @@ final class ChatNostrCoordinator {
}
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if key != nil {
if let _ = key {
if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
}
@@ -94,6 +98,57 @@ final class ChatNostrCoordinator {
}
}
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let context else { return }
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
let isFavorite = content.contains("FAVORITE:TRUE")
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
if isFavorite {
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: nostrPubkey,
nickname: senderNickname
)
}
var extractedNostrPubkey: String?
if let range = content.range(of: "NPUB:") {
let suffix = content[range.upperBound...]
let parts = suffix.components(separatedBy: "|")
if let key = parts.first {
extractedNostrPubkey = String(key)
}
} else if content.contains(":") {
let parts = content.components(separatedBy: ":")
if parts.count >= 3 {
extractedNostrPubkey = String(parts[2])
}
}
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
if isFavorite && extractedNostrPubkey != nil {
SecureLogger.info(
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: extractedNostrPubkey,
nickname: senderNickname
)
}
context.postLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
)
}
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let context else { return }
@@ -92,6 +92,7 @@ protocol ChatPrivateConversationContext: AnyObject {
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
// MARK: System messages
func addSystemMessage(_ content: String)
@@ -100,9 +101,6 @@ protocol ChatPrivateConversationContext: AnyObject {
// MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship resolved from a short 16-hex mesh
/// peer ID (matched against the IDs derived from stored noise keys).
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Persists that the peer favorited/unfavorited us (favorites store write).
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
/// Posts the incoming-private-message local notification.
@@ -199,10 +197,6 @@ extension ChatViewModel: ChatPrivateConversationContext {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
}
// `favoriteRelationship(forPeerID:)` is shared with
// `ChatPeerIdentityContext`; its witness lives in
// `ChatPeerIdentityCoordinator.swift`.
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noiseKey,
@@ -251,15 +245,10 @@ final class ChatPrivateConversationCoordinator {
return
}
// Resolve the favorite behind this conversation. It may be keyed by
// the full 64-hex noise-key ID (offline favorite row) or the short
// 16-hex mesh ID the raw hex bytes of a short ID are a routing ID,
// never a noise key, so they must not be used as a favorites key.
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = context.isPeerConnected(peerID)
let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
?? context.favoriteRelationship(forPeerID: peerID)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
@@ -416,32 +405,9 @@ final class ChatPrivateConversationCoordinator {
return
}
// Prefer the favorite's stored nickname when the sender resolved to a
// known noise key; the Nostr display name is a geohash-scoped
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
let senderName: String = {
if let noiseKey = convKey.noiseKey,
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
!favoriteNickname.isEmpty {
return favoriteNickname
}
return context.displayNameForNostrPubkey(senderPubkey)
}()
// Favorite notifications ride the PM channel over Nostr too; intercept
// them so they update the relationship instead of rendering as text.
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotification(
pm.content,
from: convKey,
senderNickname: senderName
)
return
}
if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage(
id: messageId,
sender: senderName,
@@ -520,6 +486,93 @@ final class ChatPrivateConversationCoordinator {
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
}
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
let messageId = pm.messageID
let messageContent = pm.content
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
if let key = actualSenderNoiseKey {
handleFavoriteNotificationFromMesh(
messageContent,
from: PeerID(hexData: key),
senderNickname: senderNickname
)
}
return
}
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
return
}
let wasReadBefore = context.sentReadReceipts.contains(messageId)
var isViewingThisChat = false
if context.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = context.selectedPrivateChatPeer,
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerNoiseKey == key {
isViewingThisChat = true
}
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
let message = BitchatMessage(
id: messageId,
sender: senderNickname,
content: messageContent,
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: context.nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
context.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
key: actualSenderNoiseKey
)
if wasReadBefore {
// No-op.
} else if isViewingThisChat {
handleViewingThisChat(
message,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
senderPubkey: senderPubkey
)
} else {
markAsUnreadIfNeeded(
shouldMarkAsUnread: shouldMarkAsUnread,
targetPeerID: targetPeerID,
key: actualSenderNoiseKey,
isRecentMessage: isRecentMessage,
senderNickname: senderNickname,
messageContent: messageContent
)
}
context.notifyUIChanged()
}
func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
@@ -530,7 +583,7 @@ final class ChatPrivateConversationCoordinator {
}
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
return
}
@@ -653,10 +706,7 @@ final class ChatPrivateConversationCoordinator {
}
}
/// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
/// transport. `peerID` must resolve to a noise key a full 64-hex ID or
/// one the unified peer list knows; otherwise the notification is dropped.
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
let isFavorite = content.hasPrefix("[FAVORITED]")
let parts = content.split(separator: ":")
+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 {
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
}
@@ -1189,9 +1147,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
// Drop courier mail carried for third parties (memory and disk)
CourierStore.shared.wipe()
// Identity manager has cleared persisted identity data above
// Clear autocomplete state
@@ -1480,7 +1435,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
/// Processes IRC-style commands starting with '/'.
/// - Parameter command: The full command string including the leading slash
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
@MainActor
func handleCommand(_ command: String) {
let result = commandProcessor.process(command)
@@ -1488,29 +1443,16 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
switch result {
case .success(let message):
if let msg = message {
addCommandOutput(msg)
addSystemMessage(msg)
}
case .error(let message):
addCommandOutput(message)
addSystemMessage(message)
case .handled:
// Command was handled, no message needed
break
}
}
/// Command output belongs in the conversation where the user typed the
/// command; the public timeline is invisible while a DM is open. The DM
/// selection is read *after* processing so commands that switch chats
/// (`/msg`) print into the conversation they just opened.
@MainActor
private func addCommandOutput(_ content: String) {
if let peerID = selectedPrivateChatPeer {
addLocalPrivateSystemMessage(content, to: peerID)
} else {
addSystemMessage(content)
}
}
// MARK: - Message Reception
@MainActor
@@ -100,28 +100,11 @@ private extension ChatViewModelBootstrapper {
category: .session
)
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
)
}
}
// A message with no reachable transport that was handed to a courier
// shows a distinct "carried" state instead of sitting in "sending"
// forever. Never downgrade a confirmed receipt: the courier copy can
// race direct delivery when the peer reappears.
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
break
default:
SecureLogger.debug(
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
category: .session
)
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
}
}
viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel)
@@ -109,6 +109,11 @@ extension ChatViewModel {
)
}
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
}
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
@@ -121,6 +121,25 @@ extension ChatViewModel {
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
}
@MainActor
func handlePrivateMessage(
_ payload: NoisePayload,
actualSenderNoiseKey: Data?,
senderNickname: String,
targetPeerID: PeerID,
messageTimestamp: Date,
senderPubkey: String
) {
privateConversationCoordinator.handlePrivateMessage(
payload,
actualSenderNoiseKey: actualSenderNoiseKey,
senderNickname: senderNickname,
targetPeerID: targetPeerID,
messageTimestamp: messageTimestamp,
senderPubkey: senderPubkey
)
}
@MainActor
func handlePrivateMessage(_ message: BitchatMessage) {
privateConversationCoordinator.handlePrivateMessage(message)
@@ -171,8 +190,8 @@ extension ChatViewModel {
}
@MainActor
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.handleFavoriteNotification(
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
content,
from: peerID,
senderNickname: senderNickname
@@ -442,7 +442,8 @@ final class NostrInboundPipeline {
}
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
/// store. Lives here because the inbound DM path needs it per message.
/// store. Lives here because the inbound DM path needs it per message;
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
guard let context else { return nil }
+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 {
static let title: LocalizedStringKey = "app_info.privacy.title"
static let noTracking = AppInfoFeatureInfo(
@@ -138,8 +118,14 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
Button(action: { dismiss() }) {
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)
}
// Symbols legend
VStack(alignment: .leading, spacing: 10) {
SectionHeader(Strings.Legend.title)
ForEach(Strings.Legend.items, id: \.icon) { item in
HStack(alignment: .top, spacing: 12) {
Image(systemName: item.icon)
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.frame(width: 30)
Text(item.text)
.bitchatFont(size: 13)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
Spacer()
}
.accessibilityElement(children: .combine)
}
}
// Privacy
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.Privacy.title)
@@ -14,46 +14,29 @@ struct CommandSuggestionsView: View {
@Binding var messageText: String
/// The command already typed in full, once arguments have begun.
private var typedCommandAlias: String? {
guard messageText.hasPrefix("/"),
let spaceIndex = messageText.firstIndex(of: " ")
else { return nil }
return String(messageText[..<spaceIndex]).lowercased()
}
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") else { return [] }
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
// While arguments are being typed, keep the matched command's usage
// row visible instead of vanishing at the first space.
if let typed = typedCommandAlias {
return commands.filter { $0.alias == typed && $0.placeholder != nil }
}
return commands.filter { command in
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
command.alias.starts(with: messageText.lowercased())
}
}
var body: some View {
// Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row
// off-center.
if !filteredCommands.isEmpty {
let isUsageReminder = typedCommandAlias != nil
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
// In usage-reminder mode the row is informational; an
// insert here would wipe the arguments being typed.
guard !isUsageReminder else { return }
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -9,47 +9,6 @@
import SwiftUI
import BitFoundation
extension DeliveryStatus {
/// Localized, user-facing description of the status. Used for macOS
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver
/// the glyphs alone are unexplained 10pt icons.
var bitchatDescription: String {
switch self {
case .sending:
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
case .sent:
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
case .carried:
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
case .delivered(let nickname, _):
return String(
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
locale: .current,
nickname
)
case .read(let nickname, _):
return String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
case .failed(let reason):
return String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
case .partiallyDelivered(let reached, let total):
return String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
}
struct DeliveryStatusView: View {
@ThemedPalette private var palette
let status: DeliveryStatus
@@ -60,34 +19,56 @@ struct DeliveryStatusView: View {
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 {
statusGlyph
.help(status.bitchatDescription)
.accessibilityElement(children: .ignore)
.accessibilityLabel(status.bitchatDescription)
static func read(by nickname: String) -> String {
String(
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
locale: .current,
nickname
)
}
static func failed(_ reason: String) -> String {
String(
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
locale: .current,
reason
)
}
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
String(
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
locale: .current,
reached,
total
)
}
}
@ViewBuilder
private var statusGlyph: some View {
// MARK: - Body
var body: some View {
switch status {
case .sending:
Image(systemName: "circle")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .sent:
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.6))
case .carried:
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor.opacity(0.8))
case .delivered:
case .delivered(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
@@ -95,22 +76,24 @@ struct DeliveryStatusView: View {
.font(.bitchatSystem(size: 10))
}
.foregroundColor(textColor.opacity(0.8))
case .read:
// Filled variant so read vs delivered is legible without color.
HStack(spacing: 0) {
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
Image(systemName: "checkmark.circle.fill")
.font(.bitchatSystem(size: 9, weight: .bold))
.help(Strings.delivered(to: nickname))
case .read(let nickname, _):
HStack(spacing: -2) {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
}
.foregroundColor(palette.accentBlue)
case .failed:
.help(Strings.read(by: nickname))
case .failed(let reason):
Image(systemName: "exclamationmark.triangle")
.font(.bitchatSystem(size: 10))
.foregroundColor(Color.red.opacity(0.8))
.help(Strings.failed(reason))
case .partiallyDelivered(let reached, let total):
HStack(spacing: 1) {
Image(systemName: "checkmark")
@@ -119,6 +102,7 @@ struct DeliveryStatusView: View {
.bitchatFont(size: 10)
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
}
}
}
@@ -127,7 +111,6 @@ struct DeliveryStatusView: View {
let statuses: [DeliveryStatus] = [
.sending,
.sent,
.carried,
.delivered(to: "John Doe", at: Date()),
.read(by: "Jane Doe", at: Date()),
.failed(reason: "Offline"),
@@ -57,7 +57,7 @@ struct PaymentChipView: View {
private var fgColor: Color { palette.primary }
private var bgColor: Color {
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) }
@@ -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 {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
@@ -25,7 +24,6 @@ struct TextMessageView: View {
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = []
@State private var showDeliveryDetail = false
init(message: BitchatMessage) {
self.message = message
@@ -37,59 +35,19 @@ struct TextMessageView: View {
// Precompute heavy token scans once per row
let cashuLinks = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks()
// Baseline alignment keeps the lock and delivery glyphs on the
// first text line; a fixed top padding left the lock's solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
// Delivery status indicator for private messages
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 2)
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
@@ -102,7 +60,7 @@ struct TextMessageView: View {
else { expandedMessageIDs.insert(message.id) }
}
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(palette.accentBlue)
.foregroundColor(Color.blue)
.padding(.top, 4)
}
@@ -120,12 +78,6 @@ struct TextMessageView: View {
.padding(.leading, 2)
}
}
// Collapse the revealed caption when the status advances (e.g.
// sending sent delivered) so a detail opened for one state
// doesn't linger and silently morph into another.
.onChange(of: deliveryStatus) { _ in
showDeliveryDetail = false
}
}
}
+8 -67
View File
@@ -6,7 +6,6 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@@ -44,6 +43,7 @@ struct ContentComposerView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
}
.themedOverlayPanel()
@@ -60,8 +60,10 @@ struct ContentComposerView: View {
TextField(
"",
text: $messageText,
prompt: Text(placeholderText)
.foregroundColor(palette.secondary.opacity(0.6))
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.bitchatFont(size: 15)
@@ -108,33 +110,6 @@ struct ContentComposerView: View {
}
private extension ContentComposerView {
/// States where a message will land: the DM partner's name for private
/// chats, the channel (and its public nature) otherwise so a stressed
/// user never has to guess who can read what they're typing.
var placeholderText: String {
if let header = privateConversationModel.selectedHeaderState {
// A geohash-DM display name already carries its own "#geohash/@name"
// form, so it must not get another "@" prefix; a mesh nickname does.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
return String(
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
locale: .current,
target
)
}
switch locationChannelsModel.selectedChannel {
case .mesh:
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
case .location(let channel):
return String(
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
locale: .current,
channel.geohash
)
}
}
var recordingIndicator: some View {
HStack(spacing: 12) {
Image(systemName: "waveform.circle.fill")
@@ -183,19 +158,7 @@ private extension ContentComposerView {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel(
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
)
.accessibilityHint(
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
)
.accessibilityAddTraits(.isButton)
// The long-press camera path is unreachable for VoiceOver users;
// mirror it as a named action.
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
imagePickerSourceType = .camera
showImagePicker = true
}
.accessibilityLabel("Tap for library, long press for camera")
#else
Button(action: { showMacImagePicker = true }) {
Image(systemName: "photo.circle.fill")
@@ -204,9 +167,7 @@ private extension ContentComposerView {
.frame(width: 36, height: 36)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
)
.accessibilityLabel("Choose photo")
#endif
}
@@ -248,27 +209,7 @@ private extension ContentComposerView {
}
)
)
.accessibilityLabel(
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
)
.accessibilityValue(
voiceRecordingVM.state.isActive
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
: ""
)
.accessibilityHint(
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
)
.accessibilityAddTraits(.isButton)
// Press-and-hold drag gestures can't be activated by VoiceOver;
// give it a start/stop toggle as the default action.
.accessibilityAction {
if voiceRecordingVM.state.isActive {
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
} else {
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
}
}
.accessibilityLabel("Hold to record a voice note")
}
func sendButtonView(enabled: Bool) -> some View {
+11 -61
View File
@@ -22,9 +22,6 @@ struct ContentHeaderView: View {
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
/// Courier envelopes this device is carrying for offline third parties.
@State private var carriedMailCount = 0
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
@@ -36,16 +33,6 @@ struct ContentHeaderView: View {
.onTapGesture(count: 1) {
appChromeModel.presentAppInfo()
}
// This is the only entry point to App Info, but it reads as
// static text; surface the tap. (The triple-tap panic wipe
// stays undiscoverable on purpose it's destructive.)
.accessibilityAddTraits(.isButton)
.accessibilityHint(
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
)
.accessibilityAction {
appChromeModel.presentAppInfo()
}
HStack(spacing: 0) {
Text(verbatim: "@")
@@ -91,23 +78,6 @@ struct ContentHeaderView: View {
}()
HStack(spacing: 2) {
if carriedMailCount > 0 {
Image(systemName: "figure.walk")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary.opacity(0.8))
.headerTapTarget()
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
locale: .current,
carriedMailCount
)
)
.help(
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
)
}
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
@@ -213,13 +183,6 @@ struct ContentHeaderView: View {
headerOtherPeersCount
)
)
// Connected-vs-nobody is otherwise encoded only in the icon's
// color; say it.
.accessibilityValue(
headerPeersReachable
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
)
}
.layoutPriority(3)
.sheet(isPresented: $showVerifySheet) {
@@ -227,16 +190,8 @@ struct ContentHeaderView: View {
.environmentObject(verificationModel)
}
}
// Fixed height is load-bearing: children fill the bar with
// .frame(maxHeight: .infinity) tap targets, so an open-ended
// minHeight lets the header expand to swallow the whole screen.
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
// Type.
.frame(height: headerHeight)
.padding(.horizontal, 12)
.onReceive(CourierStore.shared.$carriedCount) { count in
carriedMailCount = count
}
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
.environmentObject(locationChannelsModel)
@@ -311,25 +266,14 @@ private extension ContentHeaderView {
dynamicTypeSize.isAccessibilitySize ? 2 : 1
}
/// Whether anyone is actually reachable on the current channel the
/// state the count icon's color encodes visually.
var headerPeersReachable: Bool {
switch locationChannelsModel.selectedChannel {
case .location:
return peerListModel.visibleGeohashPeerCount > 0
case .mesh:
return peerListModel.connectedMeshPeerCount > 0
}
}
func channelPeopleCountAndColor() -> (Int, Color) {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
return (count, count > 0 ? palette.locationAccent : palette.secondary)
return (count, count > 0 ? palette.locationAccent : Color.secondary)
case .mesh:
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)
}
}
@@ -349,10 +293,16 @@ private struct ContentLocationNotesUnavailableView: View {
Text("content.notes.title")
.bitchatFont(size: 16, weight: .bold)
Spacer()
SheetCloseButton { showLocationNotes = false }
.foregroundColor(palette.primary)
Button(action: { showLocationNotes = false }) {
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)
.themedChromePanel(edge: .top)
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 privateConversationModel: PrivateConversationModel
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@@ -160,23 +159,24 @@ private struct ContentPeopleListView: View {
.font(.bitchatSystem(size: 14))
}
.buttonStyle(.plain)
// .help maps to the accessibility *hint* on iOS, so the
// button still needs a spoken name.
.accessibilityLabel(
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
)
.help(
String(localized: "content.help.verification", comment: "Help text for verification button")
)
}
SheetCloseButton {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
dismiss()
showSidebar = false
showVerifySheet = false
privateConversationModel.endConversation()
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
let activeText = String.localizedStringWithFormat(
@@ -198,13 +198,13 @@ private struct ContentPeopleListView: View {
Text(subtitle)
.foregroundColor(subtitleColor)
Text(activeText)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
.bitchatFont(size: 12)
} else {
Text(activeText)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
@@ -231,13 +231,6 @@ private struct ContentPeopleListView: View {
},
onShowFingerprint: { peerID in
appChromeModel.showFingerprint(for: peerID)
},
onToggleBlock: { peer in
if peer.isBlocked {
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
} else {
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
}
}
)
}
@@ -340,9 +333,6 @@ private struct ContentPrivateChatSheetView: View {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
// Same visual box + 44pt hit target as SheetCloseButton.
.frame(width: 32, height: 32)
.contentShape(Rectangle().inset(by: -6))
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -356,20 +346,24 @@ private struct ContentPrivateChatSheetView: View {
Spacer(minLength: 0)
SheetCloseButton {
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
privateConversationModel.endConversation()
showSidebar = true
}
}) {
Image(systemName: "xmark")
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
.accessibilityLabel("Close")
}
// minHeight so scaled text at accessibility sizes grows the
// bar instead of clipping inside it.
.frame(minHeight: headerHeight)
.frame(height: headerHeight)
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.modifier(PrivateHeaderChrome())
.themedSurface()
}
MessageListView(
@@ -391,8 +385,6 @@ private struct ContentPrivateChatSheetView: View {
Divider()
}
privacyCaption
#if os(iOS)
ContentComposerView(
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 {
@@ -523,30 +452,17 @@ private struct ContentPrivateHeaderInfoButton: View {
.foregroundColor(.purple)
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
case .offline:
// Absence of a glyph was the only offline signal; say it.
Text("mesh_peers.state.offline")
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
EmptyView()
}
Text(headerState.displayName)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
// Middle truncation keeps the identity suffix visible on
// long nicknames instead of wrapping into the fixed-height
// header.
.lineLimit(1)
.truncationMode(.middle)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 14))
// Optical centering: the lock glyphs' ink is bottom-heavy
// (solid body, thin shackle), so geometric centering reads
// ~1pt low next to the name. The seal badge is symmetric
// and needs no lift.
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? palette.primary
@@ -573,6 +489,6 @@ private struct ContentPrivateHeaderInfoButton: View {
.accessibilityHint(
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()
SheetCloseButton { dismiss() }
.foregroundColor(textColor)
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
}
.padding()
@@ -80,7 +83,7 @@ struct FingerprintView: View {
Spacer()
}
.padding()
.background(palette.secondary.opacity(0.1))
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
// Their fingerprint
@@ -98,7 +101,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1))
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.contextMenu {
Button(Strings.copy) {
@@ -132,7 +135,7 @@ struct FingerprintView: View {
.fixedSize(horizontal: false, vertical: true)
.padding()
.frame(maxWidth: .infinity)
.background(palette.secondary.opacity(0.1))
.background(Color.gray.opacity(0.1))
.cornerRadius(8)
.contextMenu {
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 unblock: LocalizedStringKey = "geohash_people.action.unblock"
static let block: LocalizedStringKey = "geohash_people.action.block"
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
}
var body: some View {
@@ -53,12 +46,7 @@ struct GeohashPeopleList: View {
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
let rowColor: Color = person.isMe ? .orange : assignedColor
Image(systemName: icon)
// 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)
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
@@ -66,8 +54,6 @@ struct GeohashPeopleList: View {
.bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
@@ -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
@@ -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 invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
static func meshTitle(_ count: Int) -> String {
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
@@ -106,7 +103,7 @@ struct LocationChannelsSheet: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
Group {
switch locationChannelsModel.permissionState {
@@ -125,7 +122,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
}
@@ -172,7 +169,13 @@ struct LocationChannelsSheet: 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 {
@@ -207,10 +210,7 @@ struct LocationChannelsSheet: View {
}
.buttonStyle(.plain)
.padding(.leading, 8)
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
}
) {
locationChannelsModel.markTeleported(for: channel.geohash, false)
locationChannelsModel.select(ChannelID.location(channel))
@@ -277,7 +277,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 2) {
Text(verbatim: "#")
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never)
@@ -319,7 +319,7 @@ struct LocationChannelsSheet: View {
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(palette.secondary.opacity(0.12))
.background(Color.secondary.opacity(0.12))
.cornerRadius(6)
.opacity(isValid ? 1.0 : 0.4)
.disabled(!isValid)
@@ -336,7 +336,7 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
let level = levelForLength(gh.count)
@@ -357,10 +357,7 @@ struct LocationChannelsSheet: View {
}
.buttonStyle(.plain)
.padding(.leading, 8)
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
},
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
}
) {
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
@@ -402,8 +399,6 @@ struct LocationChannelsSheet: View {
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
accessoryActionTitle: String? = nil,
accessoryAction: (() -> Void)? = nil,
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
@@ -414,17 +409,17 @@ struct LocationChannelsSheet: View {
Text(parts.base)
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? palette.primary)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
@@ -439,19 +434,6 @@ struct LocationChannelsSheet: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(perform: action)
// The row is a plain HStack with a tap gesture, which VoiceOver reads
// as disconnected static text. Expose it as one activatable button;
// the visible bookmark accessory is mirrored as a named action.
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
.accessibilityHint(Strings.switchChannelHint)
.accessibilityAction(.default, action)
.accessibilityActions {
if let accessoryActionTitle, let accessoryAction {
Button(accessoryActionTitle, action: accessoryAction)
}
}
}
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -495,16 +477,16 @@ extension LocationChannelsSheet {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(palette.primary)
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(palette.secondary.opacity(0.12))
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
+14 -7
View File
@@ -30,6 +30,7 @@ struct LocationNotesView: View {
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
static let description: LocalizedStringKey = "location_notes.description"
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
@@ -96,7 +97,13 @@ struct LocationNotesView: 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 {
@@ -119,12 +126,12 @@ struct LocationNotesView: View {
}
Text(Strings.description)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
@@ -173,7 +180,7 @@ struct LocationNotesView: View {
if !ts.isEmpty {
Text(ts)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
Spacer()
}
@@ -190,7 +197,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() }
.bitchatFont(size: 12)
.buttonStyle(.plain)
@@ -203,7 +210,7 @@ struct LocationNotesView: View {
ProgressView()
Text(Strings.loadingNotes)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
Spacer()
}
.padding(.vertical, 8)
@@ -215,7 +222,7 @@ struct LocationNotesView: View {
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.bitchatFont(size: 12)
.foregroundColor(palette.secondary)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
}
+19 -127
View File
@@ -9,7 +9,6 @@ private typealias PlatformImage = NSImage
#endif
struct BlockRevealImageView: View {
@ThemedPalette private var palette
private let url: URL
private let revealProgress: Double?
private let isSending: Bool
@@ -21,25 +20,6 @@ struct BlockRevealImageView: View {
@State private var platformImage: PlatformImage?
@State private var aspectRatio: CGFloat = 1
@State private var isBlurred: Bool = false
@State private var showDeleteConfirmation = false
@State private var loadFailed = false
private enum Strings {
static let tapToReveal = String(localized: "media.image.tap_to_reveal", comment: "Caption on a blurred incoming image inviting a tap to reveal it")
static let open = String(localized: "media.image.action.open", comment: "Context menu action that opens an image full screen")
static let reveal = String(localized: "media.image.action.reveal", comment: "Context menu action that reveals a blurred image")
static let hide = String(localized: "media.image.action.hide", comment: "Context menu action that re-blurs a revealed image")
static let delete = String(localized: "media.image.action.delete", comment: "Context menu action that deletes a received image")
static let deleteConfirmTitle = String(localized: "media.image.delete_confirm_title", comment: "Title of the confirmation dialog before deleting a received image")
static let deleteConfirmMessage = String(localized: "media.image.delete_confirm_message", comment: "Body of the confirmation dialog before deleting a received image")
static let hiddenImage = String(localized: "media.image.accessibility.hidden", comment: "Accessibility label for a blurred incoming image")
static let revealedImage = String(localized: "media.image.accessibility.revealed", comment: "Accessibility label for a revealed image")
static let revealHint = String(localized: "media.image.accessibility.hint.reveal", comment: "Accessibility hint for a blurred image; activating it reveals the image")
static let openHint = String(localized: "media.image.accessibility.hint.open", comment: "Accessibility hint for a revealed image; activating it opens the image full screen")
static let sendingImage = String(localized: "media.image.accessibility.sending", comment: "Accessibility label for an image that is still sending")
static let unavailableImage = String(localized: "media.image.accessibility.unavailable", comment: "Accessibility label for an image whose file could not be loaded")
static let cancelSend = String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
}
init(
url: URL,
@@ -89,32 +69,20 @@ struct BlockRevealImageView: View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.black.opacity(0.35))
.overlay(
VStack(spacing: 6) {
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
Text(verbatim: Strings.tapToReveal)
// Themed: monospaced under matrix,
// system under liquid glass.
.bitchatFont(size: 12, weight: .medium)
}
.foregroundColor(.white.opacity(0.85))
Image(systemName: "eye.slash.fill")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(.white.opacity(0.85))
)
}
}
} else {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(palette.secondary.opacity(0.2))
.fill(Color.gray.opacity(0.2))
.frame(height: 200)
.overlay {
if loadFailed {
Image(systemName: "photo")
.font(.bitchatSystem(size: 24, weight: .semibold))
.foregroundColor(palette.secondary)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}
.overlay(
ProgressView()
.progressViewStyle(.circular)
)
}
if let onCancel = onCancel, isSending {
@@ -127,7 +95,6 @@ struct BlockRevealImageView: View {
.padding(8)
}
.buttonStyle(.plain)
.accessibilityLabel(Strings.cancelSend)
}
}
.onAppear {
@@ -139,105 +106,30 @@ struct BlockRevealImageView: View {
loadImage()
}
.gesture(mainGesture)
.contextMenu {
if isSending {
cancelSendAction
} else {
imageActions
}
}
.confirmationDialog(
Strings.deleteConfirmTitle,
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button(Strings.delete, role: .destructive) {
onDelete?()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text(verbatim: Strings.deleteConfirmMessage)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityLabelText)
.accessibilityHint(accessibilityHintText)
.accessibilityAddTraits(isSending || loadFailed ? [] : .isButton)
.accessibilityActions {
if isSending {
// children: .ignore collapses the visible cancel button, so
// expose it as an action while the send is in flight.
cancelSendAction
} else {
imageActions
}
}
}
@ViewBuilder
private var cancelSendAction: some View {
if let onCancel {
Button(Strings.cancelSend, action: onCancel)
}
}
@ViewBuilder
private var imageActions: some View {
// Open/reveal/hide would act on a file that failed to load, so only
// offer delete (when available) to let users clean up the attachment.
if !loadFailed {
if isBlurred {
Button(Strings.reveal) {
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
}
} else {
Button(Strings.open) { onOpen?() }
Button(Strings.hide) {
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
}
}
}
if onDelete != nil {
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
}
}
private var accessibilityLabelText: String {
if isSending { return Strings.sendingImage }
if loadFailed { return Strings.unavailableImage }
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
}
private var accessibilityHintText: String {
if isSending || loadFailed { return "" }
return isBlurred ? Strings.revealHint : Strings.openHint
}
private func loadImage() {
loadFailed = false
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
let image = UIImage(contentsOfFile: url.path)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
let image = NSImage(contentsOf: url)
guard let image = NSImage(contentsOf: url) else { return }
#endif
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
DispatchQueue.main.async {
guard let image else {
self.loadFailed = true
return
}
self.platformImage = image
self.aspectRatio = 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 {
let doubleTap = TapGesture(count: 2).onEnded {
guard !isSending else { return }
onDelete?()
}
let singleTap = TapGesture().onEnded {
guard !isSending, !loadFailed else { return }
guard !isSending else { return }
if isBlurred {
withAnimation(.easeOut(duration: 0.2)) {
isBlurred = false
@@ -247,7 +139,7 @@ struct BlockRevealImageView: View {
}
}
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 vertical = value.translation.height
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 {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
@@ -21,7 +20,6 @@ struct MediaMessageView: View {
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
@State private var showDeliveryDetail = false
@Binding var imagePreviewURL: URL?
@@ -37,93 +35,46 @@ struct MediaMessageView: View {
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
// Baseline alignment (via the header text inside the VStack) keeps the
// lock on the header line; a fixed top padding left its solid body
// hanging below the line's visual center.
HStack(alignment: .firstTextBaseline, spacing: 0) {
if message.isPrivate {
Image(systemName: "lock.fill")
.font(.bitchatSystem(size: 8))
.foregroundColor(Color.orange.opacity(0.75))
.padding(.trailing, 4)
.accessibilityHidden(true)
}
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages. Tappable:
// .help() tooltips only exist on macOS, so iOS users get the
// explanation as a caption under the row instead.
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
Button {
showDeliveryDetail.toggle()
} label: {
DeliveryStatusView(status: status)
.padding(.leading, 4)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
)
}
}
// Failure reasons stay visible without a tap; other statuses
// reveal on demand.
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)
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = deliveryStatus {
if case .failed = status {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(Color.red.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
} else if showDeliveryDetail {
Text(verbatim: status.bitchatDescription)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.fixedSize(horizontal: false, vertical: true)
}
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { conversationUIModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
.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) {
@@ -139,7 +90,7 @@ struct MediaMessageView: View {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .carried, .read, .delivered, .failed:
case .sent, .read, .delivered, .failed:
break
}
}
+2 -13
View File
@@ -28,9 +28,7 @@ struct VoiceNoteView: View {
}
private var backgroundColor: Color {
// Palette-based and slightly translucent so the card doesn't sit as
// an opaque white/black box over the glass gradient.
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
}
private var borderColor: Color {
@@ -52,12 +50,6 @@ struct VoiceNoteView: View {
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
.accessibilityLabel(
playback.isPlaying
? String(localized: "media.voice.accessibility.pause", comment: "Accessibility label for pausing voice note playback")
: String(localized: "media.voice.accessibility.play", comment: "Accessibility label for playing a voice note")
)
.accessibilityValue(playbackLabel)
WaveformView(
samples: samples,
@@ -71,7 +63,7 @@ struct VoiceNoteView: View {
Text(playbackLabel)
.bitchatFont(size: 13)
.foregroundColor(palette.secondary)
.foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending {
Button(action: onCancel) {
@@ -82,9 +74,6 @@ struct VoiceNoteView: View {
.foregroundColor(.white)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "media.accessibility.cancel_send", comment: "Accessibility label for the cancel button on an in-flight media send")
)
}
}
.padding(12)
+1 -1
View File
@@ -42,7 +42,7 @@ struct WaveformView: View {
} else if let send = clampedSend, binPosition <= send {
color = palette.accentBlue
} else {
color = palette.secondary.opacity(0.35)
color = Color.gray.opacity(0.35)
}
context.fill(Path(rect), with: .color(color))
}
+1 -100
View File
@@ -7,9 +7,6 @@ struct MeshPeerList: View {
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
/// Optional so existing call sites (and previews/tests) keep compiling;
/// when absent the block/unblock context-menu entry is hidden.
var onToggleBlock: ((MeshPeerRow) -> Void)? = nil
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -18,20 +15,6 @@ struct MeshPeerList: View {
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator")
static let reachable = String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator")
static let nostr = String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator")
static let offline = String(localized: "mesh_peers.state.offline", comment: "State label for a peer that is not currently reachable")
static let favorite = String(localized: "mesh_peers.state.favorite", comment: "State label for a favorited peer")
static let unread = String(localized: "mesh_peers.state.unread", comment: "State label for a peer with unread private messages")
static let blocked = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
static let addFavorite = String(localized: "content.accessibility.add_favorite", comment: "Accessibility label to add a favorite")
static let removeFavorite = String(localized: "content.accessibility.remove_favorite", comment: "Accessibility label to remove a favorite")
static let showFingerprint = String(localized: "mesh_peers.action.fingerprint", comment: "Context menu action that shows a peer's fingerprint/verification screen")
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
static let directMessage = String(localized: "content.actions.direct_message", comment: "Action that opens a private chat with the person")
static let block = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
static let unblock = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
}
var body: some View {
@@ -66,25 +49,21 @@ struct MeshPeerList: View {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.connected)
} else if peer.isReachable {
// Mesh-reachable (relayed): point.3 icon
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 10))
.foregroundColor(baseColor)
.help(Strings.reachable)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.bitchatSystem(size: 10))
.foregroundColor(.purple)
.help(Strings.nostr)
} else {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.foregroundColor(palette.secondary)
.help(Strings.offline)
}
let (base, suffix) = peer.displayName.splitSuffix()
@@ -92,8 +71,6 @@ struct MeshPeerList: View {
Text(base)
.bitchatFont(size: 14)
.foregroundColor(baseColor)
.lineLimit(1)
.truncationMode(.tail)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
@@ -114,10 +91,6 @@ struct MeshPeerList: View {
if let icon = peer.encryptionStatus.icon {
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
// Optical centering: lock glyph ink is
// bottom-heavy, so geometric centering
// reads low next to the name.
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
} else {
@@ -130,7 +103,6 @@ struct MeshPeerList: View {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.bitchatSystem(size: 10))
.offset(y: icon.hasPrefix("lock") ? -0.5 : 0)
.foregroundColor(baseColor)
}
}
@@ -151,11 +123,6 @@ struct MeshPeerList: View {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
// Widen the tap target beyond the bare glyph;
// height stays row-bound so neighboring rows
// keep their own taps.
.frame(width: 36)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
@@ -164,53 +131,8 @@ struct MeshPeerList: View {
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 10 : 0)
.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) } }
.contextMenu {
if !isMe {
Button(Strings.directMessage) {
onTapPeer(peer.peerID)
}
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
if peer.isBlocked {
Button(Strings.unblock) {
onToggleBlock(peer)
}
} else {
Button(Strings.block, role: .destructive) {
onToggleBlock(peer)
}
}
}
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(accessibilityDescription(for: peer))
.accessibilityAddTraits(isMe ? [] : .isButton)
.accessibilityHint(isMe ? "" : Strings.openDMHint)
.accessibilityActions {
if !isMe {
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
onToggleFavorite(peer.peerID)
}
Button(Strings.showFingerprint) {
onShowFingerprint(peer.peerID)
}
if let onToggleBlock {
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
onToggleBlock(peer)
}
}
}
}
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
}
}
// 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
@State private var showMessageActions = false
@State private var showClearConfirmation = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var unseenCount = 0
@State private var lastSeenMessageCount = 0
/// Context key the unseen counters were baselined against. Channel
/// switches swap the timeline wholesale, so a count delta is only a
/// "new messages" signal while the context is unchanged.
@State private var unseenBaselineKey = ""
@ThemedPalette private var palette
var body: some View {
let currentWindowCount: Int = {
@@ -74,9 +65,6 @@ struct MessageListView: View {
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
publicEmptyState
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
@@ -84,7 +72,6 @@ struct MessageListView: View {
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
unseenCount = 0
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
@@ -102,32 +89,13 @@ struct MessageListView: View {
}
}
.contentShape(Rectangle())
.contextMenu {
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
if showsUserActions {
// Mention and DM are redundant inside a 1:1 conversation:
// mentioning the only other participant is noise, and "DM"
// would just reopen the conversation that is already open.
if privatePeer == nil {
Button("content.actions.mention") {
insertMention(message.sender)
}
if let peerID = message.senderPeerID {
Button("content.actions.direct_message") {
privateConversationModel.openConversation(for: peerID)
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
}
Button("content.actions.hug") {
conversationUIModel.sendHug(to: message.sender)
}
Button("content.actions.slap") {
conversationUIModel.sendSlap(to: message.sender)
}
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
@@ -137,16 +105,6 @@ struct MessageListView: View {
pb.setString(message.content, forType: .string)
#endif
}
if isResendableFailedMessage(message) {
Button("content.actions.resend") {
conversationUIModel.resendFailedPrivateMessage(message)
}
}
if showsUserActions {
Button("content.actions.block", role: .destructive) {
conversationUIModel.block(peerID: message.senderPeerID, displayName: message.sender)
}
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
@@ -155,24 +113,9 @@ struct MessageListView: View {
.transaction { tx in if conversationUIModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.overlay(alignment: .bottomTrailing) {
if !isAtBottom && !messageItems.isEmpty {
jumpToLatestPill(proxy: proxy)
}
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
showClearConfirmation = true
}
.confirmationDialog(
"content.clear.confirm_title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("content.clear.confirm_action", role: .destructive) {
conversationUIModel.clearCurrentConversation()
}
Button("common.cancel", role: .cancel) {}
conversationUIModel.clearCurrentConversation()
}
.onAppear {
scrollToBottom(on: proxy)
@@ -196,7 +139,9 @@ struct MessageListView: View {
) {
Button("content.actions.mention") {
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 {
var currentContextKey: String {
if let peer = privatePeer {
return "dm:\(peer)"
}
return locationChannelsModel.selectedChannel.contextKey
}
/// Terminal-styled narration for an empty public timeline: says which
/// channel this is, that the app is waiting for peers, and where to go
/// next. Rendered inside the ScrollView; disappears with the first row.
var publicEmptyState: some View {
VStack(alignment: .leading, spacing: 6) {
switch locationChannelsModel.selectedChannel {
case .mesh:
emptyStateLine(String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is"))
emptyStateLine(String(localized: "content.empty.mesh_waiting", comment: "Second line of the empty mesh timeline saying no peers are in range yet"))
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
case .location(let channel):
emptyStateLine(
String(
format: String(localized: "content.empty.location_intro", comment: "First line of an empty geohash timeline naming the channel"),
locale: .current,
channel.geohash
)
)
emptyStateLine(String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen"))
}
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, alignment: .leading)
}
func emptyStateLine(_ text: String) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
Text(verbatim: "* \(text)\u{00A0}*")
.bitchatFont(size: 13)
.foregroundColor(palette.secondary.opacity(0.9))
.fixedSize(horizontal: false, vertical: true)
}
/// Messages the unseen counters may book as "new": rows that render as
/// human messages. System lines render as narration and whitespace-only
/// content never renders at all, so neither belongs in the pill count.
func unseenEligibleCount(in messages: [BitchatMessage]) -> Int {
messages.filter { $0.sender != "system" && !$0.content.trimmed.isEmpty }.count
}
/// Updates the unseen-count baseline for the current context and returns
/// how many messages were appended since the last observation. A context
/// change (timeline swapped wholesale) re-baselines and reports zero, so
/// cross-channel count differences are never booked as "new" messages.
func rebaselinedAppendedCount(newCount: Int) -> Int {
let key = currentContextKey
if unseenBaselineKey != key {
unseenBaselineKey = key
unseenCount = 0
lastSeenMessageCount = newCount
return 0
}
let appended = max(0, newCount - lastSeenMessageCount)
lastSeenMessageCount = newCount
return appended
}
/// A failed private text message of our own can be resent through the
/// normal send path (the context menu removes the failed original and
/// re-submits its content).
func isResendableFailedMessage(_ message: BitchatMessage) -> Bool {
guard message.isPrivate,
conversationUIModel.isSentByCurrentUser(message),
conversationUIModel.mediaAttachment(for: message) == nil,
case .some(.failed) = message.deliveryStatus
else { return false }
return true
}
/// Appends an @mention to the composer draft (never overwrites what the
/// user has already typed) and focuses the input field.
func insertMention(_ sender: String) {
let mention = "@\(sender) "
if messageText.isEmpty {
messageText = mention
} else if messageText.hasSuffix(" ") {
messageText += mention
} else {
messageText += " " + mention
}
isTextFieldFocused.wrappedValue = true
}
/// Floating pill shown while scrolled up: re-presents the isAtBottom /
/// unseenCount state the view already tracks, and jumps to the newest
/// message via the existing scrollToBottom helper.
func jumpToLatestPill(proxy: ScrollViewProxy) -> some View {
Button {
scrollToBottom(on: proxy)
} label: {
HStack(spacing: 4) {
Image(systemName: "arrow.down")
.font(.bitchatSystem(size: 11, weight: .semibold))
if unseenCount > 0 {
Text(
String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
)
.bitchatFont(size: 12, weight: .medium)
}
}
.foregroundColor(palette.primary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.themedOverlayPanel()
.padding(.trailing, 12)
.padding(.bottom, 10)
.accessibilityLabel(jumpToLatestAccessibilityLabel)
}
var jumpToLatestAccessibilityLabel: String {
let base = String(localized: "content.accessibility.jump_to_latest", comment: "Accessibility label for the jump to latest messages button")
guard unseenCount > 0 else { return base }
let count = String(
format: String(localized: "content.jump.new_count", comment: "Count of messages that arrived while scrolled up, shown in the jump-to-latest pill"),
locale: .current,
unseenCount
)
return "\(base), \(count)"
}
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
@@ -485,9 +294,6 @@ private extension MessageListView {
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
unseenCount = 0
lastSeenMessageCount = unseenEligibleCount(in: conversationMessages(for: privatePeer))
unseenBaselineKey = currentContextKey
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
@@ -510,23 +316,15 @@ private extension MessageListView {
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil else { return }
let messages = publicChatModel.messages
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
}
guard privatePeer == nil, let lastMsg = messages.last else { return }
// If the newest message is from me, always scroll to bottom
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -554,23 +352,18 @@ private extension MessageListView {
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer else { return }
let messages = privateInboxModel.messages(for: peerID)
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
guard let lastMsg = messages.last else {
// Timeline emptied (e.g. /clear): nothing below to jump to.
unseenCount = 0
guard let peerID = privatePeer,
let lastMsg = privateInboxModel.messages(for: peerID).last else {
return
}
let messages = privateInboxModel.messages(for: peerID)
// If the newest private message is from me, always scroll
let isFromSelf = conversationUIModel.isSentByCurrentUser(lastMsg)
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
unseenCount += appendedCount
return
} else {
isAtBottom = true
unseenCount = 0
}
func scrollIfNeeded(date: Date) {
@@ -598,27 +391,17 @@ private extension MessageListView {
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
// 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 {
case .mesh:
contextKey = "mesh"
break
case .location(let ch):
contextKey = "geo:\(ch.geohash)"
}
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
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()
// }
//}
+12 -13
View File
@@ -11,10 +11,7 @@ import AppKit
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
// Palette-tinted so the box follows the theme (green under matrix)
// instead of a fixed gray band over the glass gradient.
private var boxColor: Color { palette.secondary.opacity(0.1) }
private var boxColor: Color { Color.gray.opacity(0.1) }
private enum Strings {
static let title: LocalizedStringKey = "verification.my_qr.title"
@@ -53,7 +50,6 @@ struct MyQRView: View {
struct QRCodeImage: View {
let data: String
let size: CGFloat
@ThemedPalette private var palette
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
@@ -69,12 +65,12 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text(Strings.unavailable)
.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.
struct QRScanView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@ThemedPalette private var palette
var isActive: Bool = true
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
@State private var input = ""
@@ -158,7 +153,7 @@ struct QRScanView: View {
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(palette.secondary.opacity(0.4))
.border(Color.gray.opacity(0.4))
Button(Strings.validate) {
// Deduplicate: ignore if we just processed this exact QR
guard input != lastValid else {
@@ -270,7 +265,7 @@ struct CameraScannerView: UIViewRepresentable {
}
final class PreviewView: UIView {
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
@@ -290,7 +285,7 @@ struct VerificationSheetView: View {
private var backgroundColor: Color { palette.background }
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 {
VStack(spacing: 0) {
@@ -300,11 +295,15 @@ struct VerificationSheetView: View {
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
SheetCloseButton {
Button(action: {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.foregroundColor(accentColor)
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 12)
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
self.loadFirstPlainText(from: providers) { text in
if let t = text, !t.isEmpty {
// Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me"
PeerID(str: "3333333333333333"): "me",
]
// Matching query: suggestions and range are published, index resets.
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
"aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me"
"ffffeeeeddddcccc2222": "me",
]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2)
makePrivateMessage(id: "m2", timestamp: t2),
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
]
coordinator.markPrivateMessagesAsRead(from: convKey)
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
]
coordinator.markPrivateMessagesAsRead(from: peerID)
@@ -608,6 +608,34 @@ struct GeoPresenceTrackerTests {
#expect(stamped > stale)
#expect(context.appendedGeohashMessages.count == 1)
}
@Test @MainActor
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0x42, count: 32)
// The favorites store bridges the sender's npub back to a Noise key.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: sender.npub
)
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
#expect(context.addedFavorites.first?.nickname == "alice")
#expect(context.postedLocalNotifications.count == 1)
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
// Unfavorite: no store write, but the removal notification still posts.
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
#expect(context.addedFavorites.count == 1)
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
}
@Test @MainActor
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
)
),
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages
noiseKeyWithMessages,
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
]
coordinator.didUpdatePeerList([currentPeer])
@@ -225,10 +225,6 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
favoriteRelationshipsByNoiseKey[noiseKey]
}
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
}
@@ -356,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
context.privateChats[convKey] = [
makeIncomingMessage(id: "mine-1", sender: "me"),
makeIncomingMessage(id: "mine-2", sender: "me")
makeIncomingMessage(id: "mine-2", sender: "me"),
]
coordinator.handleDelivered(
@@ -553,14 +549,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
}
@Test @MainActor
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
@@ -572,79 +568,16 @@ struct ChatPrivateConversationCoordinatorContextTests {
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.count == 2)
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
// [UNFAVORITED] transition announces again.
coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice")
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
}
/// A Nostr DM whose sender resolved to a known noise key must be labeled
/// with the favorite's nickname, not the geohash-scoped anon fallback.
@Test @MainActor
func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xDA, count: 32)
let convKey = PeerID(hexData: noiseKey)
let senderPubkey = "0badc0de00112233"
// No displayNamesByPubkey entry: the geo fallback would be "anon".
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()!
let payload = NoisePayload(type: .privateMessage, data: payloadData)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
#expect(context.privateChats[convKey]?.first?.sender == "bob")
}
/// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey
/// path; they must update the relationship, not render as chat text.
@Test @MainActor
func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xEE, count: 32)
// The inbound pipeline resolves known favorites to their noise-key ID.
let convKey = PeerID(hexData: noiseKey)
let senderPubkey = "feedface99887766"
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()!
let payload = NoisePayload(type: .privateMessage, data: payloadData)
coordinator.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: MockChatPrivateConversationContext.dummyIdentity,
messageTimestamp: Date()
)
#expect(context.peerFavoritedUsUpdates.count == 1)
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
#expect(context.privateChats[convKey, default: []].isEmpty)
#expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"])
}
@Test @MainActor
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
let context = MockChatPrivateConversationContext()
@@ -669,32 +602,6 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.systemMessages.isEmpty)
}
/// Same as above, but the conversation is keyed by the SHORT mesh ID
/// the DM window was opened while the peer was on mesh, then they went
/// out of range. The favorite must resolve via the derived short ID and
/// route over Nostr instead of failing "peer not reachable".
@Test @MainActor
func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let noiseKey = Data(repeating: 0xCE, count: 32)
let shortID = PeerID(publicKey: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1bob",
nickname: "bob",
isFavorite: true,
theyFavoritedUs: true
)
coordinator.sendPrivateMessage("hello again", to: shortID)
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
#expect(context.systemMessages.isEmpty)
}
@Test @MainActor
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
let context = MockChatPrivateConversationContext()
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.privateChats[peerID] = [
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
]
coordinator.didDisconnectFromPeer(peerID)
await drainMainActorTasks()
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
context.unreadPrivateMessages = [peerID]
context.privateChats[peerID] = [
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
]
coordinator.didDisconnectFromPeer(peerID)
@@ -428,13 +428,12 @@ struct ChatViewModelDeliveryStatusTests {
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < carried < partiallyDelivered < delivered < read
// failed < sending < sent < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.carried,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
@@ -447,10 +446,9 @@ struct ChatViewModelDeliveryStatusTests {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .carried: #expect(index == 3)
case .partiallyDelivered: #expect(index == 4)
case .delivered: #expect(index == 5)
case .read: #expect(index == 6)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
}
}
}
+14 -49
View File
@@ -297,23 +297,8 @@ struct ChatViewModelNostrExtensionTests {
let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
// LocationChannelManager is a process-wide singleton: a suite
// running in parallel (e.g. CommandProcessorTests) can flip the
// selected channel mid-test, which reroutes or drops the event
// permanently no amount of waiting recovers it. Re-assert the
// channel and redeliver on each poll: every channel switch clears
// the processed-event set and the store dedups by message ID, so
// redelivery is idempotent and interference heals on the next
// poll while a genuine failure still times out.
if LocationChannelManager.shared.selectedChannel != channel {
LocationChannelManager.shared.select(channel)
}
if viewModel.activeChannel == channel {
viewModel.handleNostrEvent(signed)
}
return false
}, timeout: TestConstants.longTimeout)
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
#expect(didAppend)
}
@@ -655,10 +640,8 @@ struct ChatViewModelNostrExtensionTests {
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
}
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
/// out of the conversation transcript.
@Test @MainActor
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
@@ -666,33 +649,19 @@ struct ChatViewModelNostrExtensionTests {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Alice"
peerNickname: "Before"
)
defer {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
}
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
// The inbound pipeline resolves a known sender to their noise-key ID.
let convKey = PeerID(hexData: noiseKey)
let payloadData = try #require(
PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
)
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(
payload,
senderPubkey: identity.publicKeyHex,
convKey: convKey,
id: identity,
messageTimestamp: Date()
viewModel.handleFavoriteNotification(
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
from: identity.publicKeyHex
)
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
#expect(relationship?.theyFavoritedUs == true)
#expect(relationship?.isMutual == true)
#expect(relationship?.peerNickname == "Alice")
#expect(relationship?.peerNostrPublicKey == identity.npub)
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
#expect(relationship?.isFavorite == true)
}
@Test @MainActor
@@ -1031,11 +1000,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
// Media sends hop through Task.detached; the global executor is
// shared with every parallel test worker, so a loaded runner can
// exceed the 5s default. waitUntil returns as soon as the condition
// holds, so passing runs never pay the longer timeout.
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
@@ -1055,7 +1020,7 @@ struct ChatViewModelMediaTransferTests {
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: TestConstants.longTimeout)
}, timeout: 5.0)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
@@ -1071,7 +1036,7 @@ struct ChatViewModelMediaTransferTests {
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
@@ -1092,7 +1057,7 @@ struct ChatViewModelMediaTransferTests {
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: TestConstants.longTimeout)
}, timeout: 5.0)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
-53
View File
@@ -263,59 +263,6 @@ struct ChatViewModelCommandTests {
#expect(transport.sentPrivateMessages.isEmpty)
}
}
@Test @MainActor
func handleCommand_outputRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/help")
#expect(viewModel.privateChats[peerID]?.last?.content == CommandProcessor.helpText)
#expect(!viewModel.messages.contains { $0.content == CommandProcessor.helpText })
}
@Test @MainActor
func handleCommand_errorRoutesToOpenPrivateChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
viewModel.selectedPrivateChatPeer = peerID
viewModel.handleCommand("/bogus")
let dmContents = viewModel.privateChats[peerID]?.map(\.content) ?? []
#expect(dmContents.contains { $0.hasPrefix("unknown command: /bogus") })
#expect(!viewModel.messages.contains { $0.content.hasPrefix("unknown command: /bogus") })
}
@Test @MainActor
func handleCommand_outputRoutesToPublicTimelineWithoutOpenDM() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.handleCommand("/bogus")
#expect(viewModel.messages.last?.content.hasPrefix("unknown command: /bogus") == true)
}
@Test @MainActor
func handleCommand_msgSuccessLandsInNewlyOpenedChat() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0000000000000002")
transport.simulateConnect(peerID, nickname: "Alice")
let resolved = await TestHelpers.waitUntil({
viewModel.getPeerIDForNickname("Alice") == peerID
}, timeout: TestConstants.defaultTimeout)
#expect(resolved)
viewModel.handleCommand("/msg Alice")
#expect(viewModel.selectedPrivateChatPeer == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content == "started private chat with Alice")
#expect(!viewModel.messages.contains { $0.content == "started private chat with Alice" })
}
}
// MARK: - Composer Tests
-44
View File
@@ -303,50 +303,6 @@ struct CommandProcessorTests {
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
/// /fav must go through toggleFavorite (which persists by the real noise
/// key) not write the hex peer ID into the favorites store, and not
/// send a second favorite notification.
@MainActor
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(
contextProvider: context,
meshService: MockTransport(),
identityManager: identityManager
)
let peerID = PeerID(str: "00aa00bb00cc00dd")
context.nicknameToPeerID["alice"] = peerID
let result = await withSelectedChannel(.mesh, context: context) {
processor.process("/fav alice")
}
switch result {
case .success(let message):
#expect(message == "added alice to favorites")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
#expect(context.favoriteNotifications.isEmpty)
// The 8-byte routing ID must never be stored as a "noise key".
let bogusKey = Data(hexString: peerID.id)!
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
// Unfavoriting someone who is not a favorite is a no-op.
let unfavResult = await withSelectedChannel(.mesh, context: context) {
processor.process("/unfav alice")
}
switch unfavResult {
case .success(let message):
#expect(message == "alice is not a favorite")
default:
Issue.record("Expected success result")
}
#expect(context.toggledFavorites == [peerID])
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
-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,730 +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)
// Alice is absent from Bob's mesh, so the sender resolves to her
// full noise-key ID the stable favorite conversation not the
// short mesh ID (which Bob couldn't resolve to a nickname) and not
// the courier's identity.
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
#expect(delivered.peerID != carol.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 }
}
// Nostr-style reachability: claimed for peers with no live link (known
// npub), where prompt delivery additionally needs a relay connection.
var reachablePeers: Set<PeerID> = []
var promptDelivery = true
func isPeerReachable(_ peerID: PeerID) -> Bool {
isPeerConnected(peerID) || reachablePeers.contains(peerID)
}
func canDeliverPromptly(to peerID: PeerID) -> Bool {
isPeerReachable(peerID) && promptDelivery
}
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)
}
/// The production directory must resolve both ID forms: a 64-hex
/// noise-key ID (offline favorite row) carries the key itself, and a
/// short 16-hex ID resolves through the favorites store.
@Test @MainActor
func favoritesBackedDirectoryResolvesBothIDForms() {
let directory = CourierDirectory.favoritesBacked()
let bobKey = Data(repeating: 0xB7, count: 32)
#expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey)
FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob")
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) }
#expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey)
}
@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)
}
/// A peer can be "reachable" through a transport that cannot deliver
/// promptly (Nostr claims any favorite with a known npub, even with no
/// relay connection). The queued send must not shadow the courier: a
/// sealed copy goes to connected couriers in parallel, and receivers
/// dedup by message ID if both arrive.
@Test @MainActor
func queuedReachableSendAlsoDepositsWithCourier() {
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 transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
]
transport.reachablePeers = [bobID]
transport.promptDelivery = false
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: "m4")
#expect(transport.directSends == ["m4"])
#expect(transport.courierSends.count == 1)
#expect(transport.courierSends.first?.messageID == "m4")
#expect(transport.courierSends.first?.couriers == [carolID])
#expect(carried == ["m4"])
}
/// When the reachable transport can deliver promptly (relays up), the
/// send is trusted and no courier quota is spent.
@Test @MainActor
func promptlyDeliverableReachablePeerSkipsCourier() {
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 transport = CourierCaptureTransport()
transport.snapshots = [
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
]
transport.reachablePeers = [bobID]
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: "m5")
#expect(transport.directSends == ["m5"])
#expect(transport.courierSends.isEmpty)
#expect(carried.isEmpty)
}
}
@@ -174,7 +174,7 @@ struct IntegrationTests {
}
// 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)
helper.nodes["Bob"]!.packetDeliveryHandler = { packet in
@@ -206,7 +206,7 @@ struct IntegrationTests {
try await confirmation("Messages delivered despite churn", expectedCount: totalMessages) { completion in
// David tracks received messages
helper.nodes["David"]!.messageDeliveryHandler = { _ in
helper.nodes["David"]!.messageDeliveryHandler = { message in
completion()
}
@@ -288,7 +288,7 @@ struct IntegrationTests {
}
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 packet = TestHelpers.createTestPacket(type: MessageType.noiseEncrypted.rawValue, payload: ciphertext)
helper.nodes["Alice"]!.packetDeliveryHandler = { pkt in
@@ -121,3 +121,4 @@ final class TestNetworkHelper {
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
}
}
+6 -6
View File
@@ -52,19 +52,19 @@ struct MimeTypeTests {
@Test(arguments: [
// === Image types ===
(MimeType.jpeg, [0xFF, 0xD8, 0xFF]),
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.png, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
(MimeType.gif, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]), // "GIF89a"
(MimeType.webp, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50]), // "RIFF....WEBP"
// === Audio types ===
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
(MimeType.mp3, [0x49, 0x44, 0x33]), // "ID3"
(MimeType.wav, [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00,
0x57, 0x41, 0x56, 0x45]), // "RIFF....WAVE"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
(MimeType.ogg, [0x4F, 0x67, 0x67, 0x53]), // "OggS"
// === Application types ===
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
(MimeType.pdf, [0x25, 0x50, 0x44, 0x46]) // "%PDF"
])
func validSignatures(mime: MimeType, bytes: [UInt8]) throws {
let data = Data(bytes)
+1 -1
View File
@@ -172,7 +172,7 @@ struct NoiseCoverageTests {
Data(),
Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32)
Data(repeating: 0xFF, count: 32),
]
for invalidKey in invalidKeys {
+28 -27
View File
@@ -151,7 +151,7 @@ struct NoiseProtocolTests {
@Test func basicEncryptionDecryption() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Hello, Bob!".utf8)
let plaintext = "Hello, Bob!".data(using: .utf8)!
// Alice encrypts
let ciphertext = try aliceSession.encrypt(plaintext)
@@ -167,13 +167,13 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession, responder: bobSession)
// Alice -> Bob
let aliceMessage = Data("Hello from Alice".utf8)
let aliceMessage = "Hello from Alice".data(using: .utf8)!
let aliceCiphertext = try aliceSession.encrypt(aliceMessage)
let bobReceived = try bobSession.decrypt(aliceCiphertext)
#expect(bobReceived == aliceMessage)
// Bob -> Alice
let bobMessage = Data("Hello from Bob".utf8)
let bobMessage = "Hello from Bob".data(using: .utf8)!
let bobCiphertext = try bobSession.encrypt(bobMessage)
let aliceReceived = try aliceSession.decrypt(bobCiphertext)
#expect(aliceReceived == bobMessage)
@@ -193,7 +193,7 @@ struct NoiseProtocolTests {
}
@Test func encryptionBeforeHandshake() {
let plaintext = Data("test".utf8)
let plaintext = "test".data(using: .utf8)!
#expect(throws: NoiseSessionError.notEstablished) {
try aliceSession.encrypt(plaintext)
@@ -270,7 +270,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Encrypt with manager
let plaintext = Data("Test message".utf8)
let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceManager.encrypt(plaintext, for: alicePeerID)
// Decrypt with manager
@@ -283,7 +283,7 @@ struct NoiseProtocolTests {
@Test func tamperedCiphertextDetection() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Secret message".utf8)
let plaintext = "Secret message".data(using: .utf8)!
var ciphertext = try aliceSession.encrypt(plaintext)
// Tamper with ciphertext
@@ -304,7 +304,7 @@ struct NoiseProtocolTests {
@Test func replayPrevention() throws {
try performHandshake(initiator: aliceSession, responder: bobSession)
let plaintext = Data("Test message".utf8)
let plaintext = "Test message".data(using: .utf8)!
let ciphertext = try aliceSession.encrypt(plaintext)
// First decryption should succeed
@@ -337,7 +337,7 @@ struct NoiseProtocolTests {
try performHandshake(initiator: aliceSession2, responder: bobSession2)
// Encrypt with session 1
let plaintext = Data("Secret".utf8)
let plaintext = "Secret".data(using: .utf8)!
let ciphertext1 = try aliceSession1.encrypt(plaintext)
// Should not be able to decrypt with session 2
@@ -366,10 +366,10 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// Exchange some messages to establish nonce state
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
let message1 = try aliceManager.encrypt("Hello".data(using: .utf8)!, for: alicePeerID)
_ = 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)
// Simulate Bob restart by creating new manager with same key
@@ -391,7 +391,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
// Should be able to exchange messages with new sessions
let testMessage = Data("After restart".utf8)
let testMessage = "After restart".data(using: .utf8)!
let encrypted = try bobManagerRestarted.encrypt(testMessage, for: bobPeerID)
let decrypted = try aliceManager.decrypt(encrypted, from: alicePeerID)
#expect(decrypted == testMessage)
@@ -409,17 +409,17 @@ struct NoiseProtocolTests {
// Exchange messages to advance nonces
for i in 0..<5 {
let msg = try aliceSession.encrypt(Data("Message \(i)".utf8))
let msg = try aliceSession.encrypt("Message \(i)".data(using: .utf8)!)
_ = try bobSession.decrypt(msg)
}
// Simulate desynchronization by encrypting but not decrypting
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
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) {
try bobSession.decrypt(desyncMessage)
}
@@ -434,11 +434,12 @@ struct NoiseProtocolTests {
let messageCount = 100
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
{ completion in
var encryptedMessages: [Int: Data] = [:]
// Encrypt messages sequentially to avoid nonce races in manager
for i in 0..<messageCount {
let plaintext = Data("Concurrent message \(i)".utf8)
let plaintext = "Concurrent message \(i)".data(using: .utf8)!
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
encryptedMessages[i] = encrypted
}
@@ -451,7 +452,7 @@ struct NoiseProtocolTests {
return
}
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)
completion()
} catch {
@@ -484,7 +485,7 @@ struct NoiseProtocolTests {
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
// 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
// Decryption should fail
@@ -515,7 +516,7 @@ struct NoiseProtocolTests {
#expect(bobManager.getSession(for: bobPeerID)?.isEstablished() == true)
// 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 decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
#expect(decrypted == testMessage)
@@ -538,7 +539,7 @@ struct NoiseProtocolTests {
_ = try bobManager.handleIncomingHandshake(from: bobPeerID, message: newHandshake3!)
// 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 decrypted2 = try bobManager.decrypt(encrypted2, from: bobPeerID)
#expect(decrypted2 == testMessage2)
@@ -554,18 +555,18 @@ struct NoiseProtocolTests {
// Exchange messages normally
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)
}
// Simulate desynchronization - Alice sends messages that Bob doesn't receive
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
let desyncMessage = try aliceManager.encrypt(
Data("This now succeeds".utf8), for: alicePeerID)
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
#expect(throws: Never.self) {
try bobManager.decrypt(desyncMessage, from: bobPeerID)
}
@@ -586,7 +587,7 @@ struct NoiseProtocolTests {
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
// Verify communication works again
let testResynced = Data("Resynced".utf8)
let testResynced = "Resynced".data(using: .utf8)!
let encryptedResync = try aliceManager.encrypt(testResynced, for: alicePeerID)
let decryptedResync = try bobManager.decrypt(encryptedResync, from: bobPeerID)
#expect(decryptedResync == testResynced)
@@ -899,20 +900,20 @@ struct NoiseProtocolTests {
#expect(trackingKeychain.secureClearDataCallCount > 0)
// 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 decrypted1 = try bob.decrypt(ciphertext1)
#expect(decrypted1 == plaintext1)
// 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 decrypted2 = try alice.decrypt(ciphertext2)
#expect(decrypted2 == plaintext2)
// Test multiple messages to verify cipher state is correct
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 dec = try bob.decrypt(cipher)
#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))
}
}
@@ -301,7 +301,7 @@ final class PerformanceBaselineTests: XCTestCase {
("@carol#a1b2 did you see this? https://example.com/threads/42", ["carol"]),
("checking in from the harbor #bitchat #mesh", nil),
("@bob#0042 ping me when you get this", ["bob#0042"]),
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil)
("long form update with a link https://news.example.org/articles/2026/06/mesh-networks and a tag #geohash", nil),
]
let batches: [[BitchatMessage]] = (0..<batchCount).map { batch in
(0..<batchSize).map { i in
@@ -547,7 +547,7 @@ final class PerformanceBaselineTests: XCTestCase {
"anyone near the station?",
"@bob#0042 are you on mesh too?",
"check this out https://example.com/p/123 #bitchat",
"teleport check, who's local?"
"teleport check, who's local?",
]
return try (0..<count).map { i in
try NostrProtocol.createEphemeralGeohashEvent(
+5 -10
View File
@@ -50,19 +50,14 @@ private final class DefaultTransportProbe: Transport {
struct ProtocolContractTests {
@Test
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
// Aliases must match what CommandProcessor actually accepts
// the suggestion panel is the only command-discovery surface.
#expect(CommandInfo.message.id == "msg")
#expect(CommandInfo.message.alias == "/msg")
#expect(CommandInfo.message.id == "dm")
#expect(CommandInfo.message.alias == "/dm")
#expect(CommandInfo.message.placeholder != nil)
#expect(CommandInfo.clear.placeholder == nil)
#expect(CommandInfo.favorite.description.isEmpty == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
// Favorites are rejected by the processor in geohash contexts, so
// they are suggested only in mesh.
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite) == false)
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
}
@Test
@@ -6,9 +6,12 @@ import Testing
struct BLEAnnounceHandlerTests {
private final class Recorder {
var existingNoisePublicKey: Data?
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
var dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true
@@ -35,7 +38,11 @@ struct BLEAnnounceHandlerTests {
localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey))
return recorder.signatureValid
@@ -98,12 +105,8 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
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.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
#expect(recorder.barrierCount == 1)
@@ -165,11 +168,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
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.barrierCount == 1)
#expect(recorder.upsertCalls.isEmpty)
@@ -204,9 +204,8 @@ struct BLEAnnounceHandlerTests {
recorder.signatureValid = false
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.upsertCalls.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -230,9 +229,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -251,9 +249,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
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)
}
@@ -273,9 +270,8 @@ struct BLEAnnounceHandlerTests {
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let result = handler.handle(packet, from: peerID)
handler.handle(packet, from: peerID)
#expect(result == nil)
expectNoSideEffects(recorder)
}
@@ -322,10 +318,8 @@ struct BLEAnnounceHandlerTests {
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
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.first?.isConnected == false)
#expect(recorder.uiEventDeliveries.count == 1)
@@ -381,6 +375,168 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
}
@Test
func matchingPinnedSigningKeyIsAccepted() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9A, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
// Matches the signing key encoded by makeAnnouncePacket.
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9B, count: 32)
let peerID = PeerID(publicKey: noiseKey)
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
recorder.signatureValid = true
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
// Registry has no entry (app restart or offline-peer eviction), but
// the persisted cryptographic identity still pins the victim's
// signing key. An attacker replaying the victim's noiseKey/peerID
// with their own signing key must not be treated as first contact.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9D, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = nil
recorder.existingSigningPublicKey = nil
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries == [peerID])
#expect(recorder.upsertCalls.isEmpty)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
// Legitimate returning peer: registry entry evicted, persisted pin
// matches the announced signing key accepted like a normal announce.
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9E, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
// Matches the signing key encoded by makeAnnouncePacket.
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.count == 1)
}
@Test
func registryPinnedSigningKeySkipsPersistedLookup() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9F, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
recorder.existingNoisePublicKey = noiseKey
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.persistedSigningKeyQueries.isEmpty)
#expect(recorder.upsertCalls.count == 1)
}
@Test
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0x9C, count: 32)
let peerID = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: peerID,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64),
directNeighbors: [Data(repeating: 0xAB, count: 8)]
)
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
// but the registry itself refuses to replace its pinned signing key.
let recorder = Recorder()
recorder.upsertResult = nil
let handler = makeHandler(recorder: recorder, now: now)
handler.handle(packet, from: peerID)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.persistedIdentities.isEmpty)
#expect(recorder.topologyUpdates.isEmpty)
#expect(recorder.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
#expect(recorder.afterglowDelays.isEmpty)
}
@Test
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
let now = Date(timeIntervalSince1970: 1_000)
@@ -397,14 +553,258 @@ struct BLEAnnounceHandlerTests {
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
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.uiEventDeliveries.count == 1)
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
}
@Test
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
// Real crypto: the attacker crafts a fully self-consistent announce
// (victim's noiseKey/peerID, attacker's signing key and nickname,
// valid packet signature made with the attacker's key). Without
// signing-key pinning this used to overwrite the victim's registry
// entry and persisted identity.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
final class RegistryBox {
var registry = BLEPeerRegistry()
var persistedIdentities: [AnnouncementPacket] = []
}
let box = RegistryBox()
let environment = BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
persistedSigningPublicKey: { _ in nil },
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
box.persistedIdentities.append(announcement)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
let handler = BLEAnnounceHandler(environment: environment)
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
// Legitimate announce from the victim is accepted and pinned.
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
handler.handle(victimAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// Attacker announce with a valid self-signature must be rejected.
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
handler.handle(attackerAnnounce, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(box.persistedIdentities.count == 1)
// The victim's subsequent announces (same pinned key) still work.
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
handler.handle(victimRename, from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
#expect(box.persistedIdentities.count == 2)
}
@Test
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
// Real crypto + real persistence: the victim announces and gets
// pinned, then the registry entry disappears (offline-peer eviction
// via reconcileConnectivity, or app restart which starts with an
// empty registry). The attacker replays the victim's
// noiseKey/peerID with their own signing key and a valid
// self-signature the persisted identity must still block the
// takeover, and must not be overwritten. The victim (same signing
// key) must be re-accepted.
let victim = NoiseEncryptionService(keychain: MockKeychain())
let attacker = NoiseEncryptionService(keychain: MockKeychain())
let victimNoiseKey = victim.getStaticPublicKeyData()
let peerID = PeerID(publicKey: victimNoiseKey)
let now = Date()
let identityKeychain = MockKeychain()
let identityManager = SecureIdentityStateManager(identityKeychain)
final class RegistryBox {
var registry = BLEPeerRegistry()
}
let box = RegistryBox()
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
BLEAnnounceHandlerEnvironment(
localPeerID: { PeerID(str: "0102030405060708") },
messageTTL: TransportConfig.messageTTLDefault,
now: { now },
existingPeerKeys: { peerID in
let info = box.registry.info(for: peerID)
return (info?.noisePublicKey, info?.signingPublicKey)
},
// Mirrors the BLEService wiring: fall back to the persisted
// cryptographic identity.
persistedSigningPublicKey: { peerID in
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
.compactMap { $0.signingPublicKey }
.first
},
verifySignature: { packet, signingPublicKey in
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
},
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
withRegistryBarrier: { body in body() },
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
box.registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected,
now: now
)
},
shouldEmitReconnectLog: { _, _ in false },
updateTopology: { _, _ in },
persistIdentity: { announcement in
identityManager.upsertCryptographicIdentity(
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey,
claimedNickname: announcement.nickname
)
},
dedupContains: { _ in true },
dedupMarkProcessed: { _ in },
deliverAnnounceUIEvents: { _, _, _ in },
trackPacketSeen: { _ in },
sendAnnounceBack: {},
scheduleAfterglow: { _ in }
)
}
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
let announcement = AnnouncementPacket(
nickname: nickname,
noisePublicKey: victimNoiseKey,
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode())
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: peerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: TransportConfig.messageTTLDefault
)
return try #require(signer.signPacket(packet))
}
func persistedIdentity() -> CryptographicIdentity? {
// queue.sync read; fences the manager's pending barrier writes.
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
}
// 1. Victim announces: pinned in the registry and persisted.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
// 2. Registry entry disappears (eviction / restart).
_ = box.registry.remove(peerID)
#expect(box.registry.info(for: peerID) == nil)
// 3. Attacker replay with own signing key: rejected via the persisted
// pin, and neither the registry nor the persisted identity change.
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
// 4. Victim re-announces with the same signing key: accepted again.
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.nickname == "victim")
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
// 5. Simulated app restart: a fresh identity manager reloads the pin
// from the (mock) keychain, and a fresh registry starts empty. The
// attacker replay is still rejected.
identityManager.forceSave()
let reloadedManager = SecureIdentityStateManager(identityKeychain)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
box.registry = BLEPeerRegistry()
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
#expect(box.registry.info(for: peerID) == nil)
#expect(
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
== victim.getSigningPublicKeyData()
)
// ...while the victim is accepted after the restart.
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty)
@@ -123,7 +123,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: false,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.missingSignature))
@@ -136,7 +138,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: false,
existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.invalidSignature))
@@ -148,7 +152,9 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .reject(.keyMismatch))
@@ -162,13 +168,51 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
)
#expect(decision == .verified)
#expect(decision.isVerified)
}
@Test
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
let noiseKey = Data(repeating: 0xCC, count: 32)
// Attacker replays the victim's noiseKey/peerID with their own signing
// key and a valid self-signature; the pinned key must win.
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
)
#expect(decision == .reject(.signingKeyMismatch))
#expect(!decision.isVerified)
}
@Test
func trustPolicyAcceptsMatchingPinnedSigningKey() {
let noiseKey = Data(repeating: 0xCC, count: 32)
let signingKey = Data(repeating: 0x99, count: 32)
let decision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: true,
signatureValid: true,
existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey,
existingSigningPublicKey: signingKey,
announcedSigningPublicKey: signingKey
)
#expect(decision == .verified)
}
@Test
func responsePolicyConnectsOnlyForDirectNewOrReconnectedPeers() {
let directNew = BLEAnnounceResponsePolicy.plan(
@@ -19,79 +19,6 @@ struct BLEFanoutSelectorTests {
#expect(selection.centralIDs == Set(["c2"]))
}
@Test
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["target-p", "bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"target-p": target,
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs == Set(["target-p"]))
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: nil,
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs == Set(["target-c"]))
}
@Test
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
let target = PeerID(str: "1122334455667788")
let bystander = PeerID(str: "8877665544332211")
let selection = BLEFanoutSelector.selectLinks(
peripheralIDs: ["bystander-p"],
centralIDs: ["target-c", "bystander-c"],
ingressLink: .central("target-c"),
peripheralPeerBindings: [
"bystander-p": bystander
],
centralPeerBindings: [
"target-c": target,
"bystander-c": bystander
],
directedPeerHint: target,
packetType: MessageType.courierEnvelope.rawValue,
messageID: "message-1"
)
#expect(selection.peripheralIDs.isEmpty)
#expect(selection.centralIDs.isEmpty)
}
@Test
func directedSendExcludesAllLinksToIngressPeer() {
let selection = BLEFanoutSelector.selectLinks(
@@ -6,12 +6,12 @@ import Testing
@Suite("BLE peer registry tests")
struct BLEPeerRegistryTests {
@Test("upserted announces track new, reconnect, and rename transitions")
func upsertVerifiedAnnounceTracksTransitions() {
func upsertVerifiedAnnounceTracksTransitions() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100)
let first = registry.upsertVerifiedAnnounce(
let firstResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: Data([1, 2, 3]),
@@ -19,6 +19,7 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen
)
let first = try #require(firstResult)
#expect(first.isNewPeer)
#expect(!first.wasDisconnected)
@@ -27,7 +28,7 @@ struct BLEPeerRegistryTests {
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID)
let reconnect = registry.upsertVerifiedAnnounce(
let reconnectResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]),
@@ -35,6 +36,7 @@ struct BLEPeerRegistryTests {
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
let reconnect = try #require(reconnectResult)
#expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected)
@@ -42,6 +44,85 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
}
@Test("pinned signing key cannot be silently replaced by a later announce")
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let victimSigningKey = Data(repeating: 0x42, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let pinResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen
)
#expect(pinResult != nil)
// Attacker replays the victim's noiseKey/peerID with their own
// signing key and nickname; the upsert must be refused wholesale.
let attack = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "attacker",
noisePublicKey: noiseKey,
signingPublicKey: attackerSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(attack == nil)
let info = try #require(registry.info(for: peerID))
#expect(info.nickname == "victim")
#expect(info.signingPublicKey == victimSigningKey)
// A legitimate re-announce with the pinned key is still accepted.
let legit = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "victim-renamed",
noisePublicKey: noiseKey,
signingPublicKey: victimSigningKey,
isConnected: true,
now: firstSeen.addingTimeInterval(2)
)
#expect(legit != nil)
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
}
@Test("announce without a signing key keeps the pinned key")
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x11, count: 32)
let signingKey = Data(repeating: 0x42, count: 32)
let firstSeen = Date(timeIntervalSince1970: 100)
let initialResult = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: signingKey,
isConnected: true,
now: firstSeen
)
#expect(initialResult != nil)
let update = registry.upsertVerifiedAnnounce(
peerID: peerID,
nickname: "alice",
noisePublicKey: noiseKey,
signingPublicKey: nil,
isConnected: true,
now: firstSeen.addingTimeInterval(1)
)
#expect(update != nil)
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
}
@Test("reachability keeps recent verified offline peers only when mesh is attached")
func reachabilityRequiresMeshAttachmentForOfflinePeers() {
let offlinePeer = PeerID(str: "1122334455667788")
@@ -49,21 +49,6 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
XCTAssertFalse(service.isMutualFavorite(peerKey))
}
func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((128..<160).map(UInt8.init))
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin")
// A notification arriving before the peer is known passes "Unknown".
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown")
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin")
// A real nickname still updates the stored one.
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2")
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2")
}
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
let service = FavoritesPersistenceService(keychain: MockKeychain())
let peerKey = Data((96..<128).map(UInt8.init))
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org>
//
import Combine
import Foundation
import Testing
import BitFoundation
@@ -43,9 +42,7 @@ struct NostrTransportTests {
)
)
// Offline favorites are addressed by the full 64-hex noise key, so
// both forms must resolve to the same reachability answer.
#expect(transport.isPeerReachable(fullPeerID))
#expect(!transport.isPeerReachable(fullPeerID))
#expect(transport.isPeerReachable(shortPeerID))
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
}
@@ -86,51 +83,6 @@ struct NostrTransportTests {
#expect(didRefresh)
}
@Test("Prompt delivery requires both a known npub and a relay connection")
@MainActor
func canDeliverPromptlyTracksRelayConnectivity() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Alice"
)
let connectivity = CurrentValueSubject<Bool, Never>(false)
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
loadFavorites: { [noiseKey: relationship] },
relayConnectivity: { connectivity.eraseToAnyPublisher() }
)
)
// Reachable (npub known) but relays down: the peer must not be
// treated as promptly deliverable, or the router would skip the
// courier and let the message rot in the Nostr send queue.
#expect(transport.isPeerReachable(peerID))
#expect(!transport.canDeliverPromptly(to: peerID))
connectivity.send(true)
let deliverable = await TestHelpers.waitUntil(
{ transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(deliverable)
connectivity.send(false)
let undeliverable = await TestHelpers.waitUntil(
{ !transport.canDeliverPromptly(to: peerID) },
timeout: 5.0
)
#expect(undeliverable)
}
@Test("Private message resolves short peer ID and emits decryptable packet")
@MainActor
func sendPrivateMessageResolvesShortPeerID() async throws {
@@ -421,8 +373,7 @@ struct NostrTransportTests {
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
@@ -432,8 +383,7 @@ struct NostrTransportTests {
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity
scheduleAfter: scheduleAfter
)
}
@@ -79,6 +79,100 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
}
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
let manager = SecureIdentityStateManager(MockKeychain())
let noisePublicKey = Data(repeating: 0x11, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x22, count: 32)
let attackerSigningKey = Data(repeating: 0x66, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
// Attacker upsert with a different signing key must be refused in
// full signing key AND claimed nickname stay the victim's.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
// Synchronous reads fence the manager's pending barrier writes.
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
// The legitimate peer (same signing key) can still update.
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim-renamed"
)
let renamed = await waitUntil {
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
}
XCTAssertTrue(renamed)
XCTAssertEqual(
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
}
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
let keychain = MockKeychain()
let manager = SecureIdentityStateManager(keychain)
let noisePublicKey = Data(repeating: 0x13, count: 32)
let fingerprint = noisePublicKey.sha256Fingerprint()
let peerID = PeerID(publicKey: noisePublicKey)
let victimSigningKey = Data(repeating: 0x24, count: 32)
let attackerSigningKey = Data(repeating: 0x77, count: 32)
manager.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: victimSigningKey,
claimedNickname: "victim"
)
let pinned = await waitUntil {
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
}
XCTAssertTrue(pinned)
manager.forceSave()
// Simulated app restart: the pin must survive and still refuse a
// different signing key.
let reloaded = SecureIdentityStateManager(keychain)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
reloaded.upsertCryptographicIdentity(
fingerprint: fingerprint,
noisePublicKey: noisePublicKey,
signingPublicKey: attackerSigningKey,
claimedNickname: "attacker"
)
XCTAssertEqual(
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
victimSigningKey
)
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
}
func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32)
@@ -41,155 +41,6 @@ struct UnifiedPeerServiceTests {
#expect(service.isBlocked(peerID))
}
@Test @MainActor
func setBlocked_persistsByFingerprintAndToggles() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000EE")
let fingerprint = "fp-target"
transport.peerFingerprints[peerID] = fingerprint
// Blocking resolves and persists by the peer's fingerprint.
let resolved = service.setBlocked(peerID, blocked: true)
#expect(resolved == fingerprint)
#expect(identity.isBlocked(fingerprint: fingerprint))
#expect(service.isBlocked(peerID))
// Unblocking clears it against the same identity.
let unresolved = service.setBlocked(peerID, blocked: false)
#expect(unresolved == fingerprint)
#expect(!identity.isBlocked(fingerprint: fingerprint))
#expect(!service.isBlocked(peerID))
}
// MARK: - Offline-favorite dedup (updatePeers phase 2)
/// A mutual favorite that is also on the mesh must collapse to a single
/// row keyed by the short mesh ID even when the announced nickname no
/// longer matches the one stored with the favorite.
@Test @MainActor
func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xAB, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
let meshID = PeerID(publicKey: noiseKey)
let snapshots = [TransportPeerSnapshot(
peerID: meshID,
nickname: "alice-renamed",
isConnected: true,
noisePublicKey: noiseKey,
lastSeen: Date()
)]
transport.updatePeerSnapshots(snapshots)
service.didUpdatePeerSnapshots(snapshots)
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(rows.count == 1)
#expect(rows.first?.peerID == meshID)
#expect(rows.first?.isMutualFavorite == true)
#expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1)
}
/// Same collapse must hold for a reachable-but-not-connected favorite
/// (relayed peers linger as "reachable" after their link drops).
@Test @MainActor
func updatePeers_reachableMutualFavoriteYieldsSingleRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xCD, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
let otherKey = Data(repeating: 0x11, count: 32)
let snapshots = [
// A live link is required for anyone to count as reachable.
TransportPeerSnapshot(
peerID: PeerID(publicKey: otherKey),
nickname: "carol",
isConnected: true,
noisePublicKey: otherKey,
lastSeen: Date()
),
TransportPeerSnapshot(
peerID: PeerID(publicKey: noiseKey),
nickname: "bob",
isConnected: false,
noisePublicKey: noiseKey,
lastSeen: Date()
)
]
transport.updatePeerSnapshots(snapshots)
service.didUpdatePeerSnapshots(snapshots)
let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(bobRows.count == 1)
#expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey))
#expect(bobRows.first?.isReachable == true)
}
/// A mutual favorite with no mesh presence still gets its offline row,
/// keyed by the full noise-key PeerID.
@Test @MainActor
func updatePeers_offlineMutualFavoriteGetsOfflineRow() async {
let favoritesService = FavoritesPersistenceService.shared
let transport = MockTransport()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
let noiseKey = Data(repeating: 0xEF, count: 32)
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave")
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
defer {
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
}
transport.updatePeerSnapshots([])
service.didUpdatePeerSnapshots([])
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
#expect(rows.count == 1)
#expect(rows.first?.peerID == PeerID(hexData: noiseKey))
#expect(rows.first?.isMutualFavorite == true)
}
@Test @MainActor
func setBlocked_unknownIdentityReturnsNil() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
// No fingerprint resolvable for this peer (offline & unknown).
let peerID = PeerID(str: "00000000000000FF")
#expect(service.setBlocked(peerID, blocked: true) == nil)
#expect(!service.isBlocked(peerID))
}
}
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
@@ -12,11 +12,6 @@ import Foundation
struct TestConstants {
static let defaultTimeout: TimeInterval = 5.0
static let shortTimeout: TimeInterval = 1.0
/// For positive waits on work that hops through `Task.detached` or
/// background queues: those contend with every parallel test worker for
/// the global executor, so a loaded CI runner can exceed
/// `defaultTimeout`. `waitUntil` returns as soon as the condition holds,
/// so passing runs never pay the longer timeout.
static let longTimeout: TimeInterval = 10.0
static let testNickname1 = "Alice"
+2 -2
View File
@@ -54,13 +54,13 @@ final class TestHelpers {
type: UInt8 = 0x01,
senderID: PeerID = PeerID(str: UUID().uuidString),
recipientID: PeerID? = nil,
payload: Data = Data("test payload".utf8),
payload: Data = "test payload".data(using: .utf8)!,
signature: Data? = nil,
ttl: UInt8 = 3
) -> BitchatPacket {
return BitchatPacket(
type: type,
senderID: Data(senderID.id.utf8),
senderID: senderID.id.data(using: .utf8)!,
recipientID: recipientID?.id.data(using: .utf8),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
+3 -13
View File
@@ -556,19 +556,11 @@ struct ViewSmokeTests {
@Test
func voiceAndMediaViews_renderAndWarmCaches() async throws {
let audioURL = try makeTemporaryAudioURL()
// Probed directly below. Deliberately a separate file from `audioURL`:
// `WaveformCache.shared` is process-wide and the mounted
// `VoiceNoteView` warms it for `audioURL` at the view's default bin
// width concurrently, so asserting an exact bin count for that URL
// races with the view's own cache write.
let waveformProbeURL = try makeTemporaryAudioURL()
let imageURL = try makeTemporaryImageURL()
defer {
try? FileManager.default.removeItem(at: audioURL)
try? FileManager.default.removeItem(at: waveformProbeURL)
try? FileManager.default.removeItem(at: imageURL)
WaveformCache.shared.purge(url: audioURL)
WaveformCache.shared.purge(url: waveformProbeURL)
}
let waveformView = WaveformView(
@@ -602,14 +594,12 @@ struct ViewSmokeTests {
_ = mount(voiceNoteView)
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)
}
}
playback.loadDuration()
// loadDuration hops through a background queue and back to main; poll
// instead of a fixed sleep so a loaded runner can't outlast the wait.
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
try? await Task.sleep(nanoseconds: 250_000_000)
playback.seek(to: 1.25)
playback.stop()
VoiceNotePlaybackCoordinator.shared.activate(playback)
@@ -617,7 +607,7 @@ struct ViewSmokeTests {
await VoiceRecorder.shared.cancelRecording()
#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.progress == 0)
}
+12 -12
View File
@@ -13,7 +13,7 @@ import struct Foundation.Data
struct XChaCha20Poly1305CompatTests {
@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 nonce = Data(repeating: 0x24, count: 24)
@@ -29,10 +29,10 @@ struct XChaCha20Poly1305CompatTests {
}
@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 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 decrypted = try XChaCha20Poly1305Compat.open(
@@ -47,7 +47,7 @@ struct XChaCha20Poly1305CompatTests {
}
@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 nonce1 = Data(repeating: 0x01, count: 24)
let nonce2 = Data(repeating: 0x02, count: 24)
@@ -59,7 +59,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortKey() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let shortKey = Data(repeating: 0x42, count: 16)
let nonce = Data(repeating: 0x24, count: 24)
@@ -73,7 +73,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongKey() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let longKey = Data(repeating: 0x42, count: 64)
let nonce = Data(repeating: 0x24, count: 24)
@@ -87,7 +87,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyKey() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let emptyKey = Data()
let nonce = Data(repeating: 0x24, count: 24)
@@ -116,7 +116,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnShortNonce() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32)
let shortNonce = Data(repeating: 0x24, count: 12)
@@ -130,7 +130,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnLongNonce() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32)
let longNonce = Data(repeating: 0x24, count: 32)
@@ -144,7 +144,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func sealThrowsOnEmptyNonce() {
let plaintext = Data("Test".utf8)
let plaintext = "Test".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32)
let emptyNonce = Data()
@@ -173,7 +173,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithWrongKey() throws {
let plaintext = Data("Secret".utf8)
let plaintext = "Secret".data(using: .utf8)!
let correctKey = Data(repeating: 0x42, count: 32)
let wrongKey = Data(repeating: 0x43, count: 32)
let nonce = Data(repeating: 0x24, count: 24)
@@ -195,7 +195,7 @@ struct XChaCha20Poly1305CompatTests {
}
@Test func openFailsWithTamperedCiphertext() throws {
let plaintext = Data("Secret".utf8)
let plaintext = "Secret".data(using: .utf8)!
let key = Data(repeating: 0x42, count: 32)
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
platforms: [
.iOS(.v16),
.macOS(.v13)
.macOS(.v13),
],
products: [
.library(
name: "Tor",
targets: ["Tor"]
)
),
],
dependencies: [
.package(path: "../BitLogger")
.package(path: "../BitLogger"),
],
targets: [
// Main Swift target
@@ -22,19 +22,19 @@ let package = Package(
name: "Tor",
dependencies: [
"arti",
.product(name: "BitLogger", package: "BitLogger")
.product(name: "BitLogger", package: "BitLogger"),
],
path: "Sources",
exclude: ["C"],
sources: [
"TorManager.swift",
"TorURLSession.swift",
"TorNotifications.swift"
"TorNotifications.swift",
],
linkerSettings: [
.linkedLibrary("resolv"),
.linkedLibrary("z"),
.linkedLibrary("sqlite3")
.linkedLibrary("sqlite3"),
]
),
// Binary framework containing the Rust static library.
@@ -42,6 +42,6 @@ let package = Package(
.binaryTarget(
name: "arti",
path: "Frameworks/arti.xcframework"
)
),
]
)
+1 -1
View File
@@ -21,7 +21,7 @@ let package = Package(
.target(
name: "BitFoundation",
dependencies: [
.product(name: "BitLogger", package: "BitLogger")
.product(name: "BitLogger", package: "BitLogger"),
],
path: "Sources"
),

Some files were not shown because too many files have changed in this diff Show More