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

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