mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 15:45:20 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02828731b9 | ||
|
|
f5b2150f11 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
b28fb26504 | ||
|
|
ba0861a446 | ||
|
|
424c47be82 | ||
|
|
20c1c0f687 | ||
|
|
ebdcdf83dd | ||
|
|
2c04f65c83 | ||
|
|
293380e671 | ||
|
|
5aaa209020 |
@@ -12,7 +12,10 @@ 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).
|
||||||
timeout-minutes: 15
|
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||||
|
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||||
|
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
strategy:
|
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
|
||||||
@@ -102,9 +105,14 @@ 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.
|
# catches algorithmic regressions, never runner variance. If a metric
|
||||||
|
# still lands below floor (a saturated runner can dip one), the script
|
||||||
|
# re-runs the benchmarks — appending to the same log and keeping each
|
||||||
|
# benchmark's best value per metric — so noise clears on retry while a
|
||||||
|
# real regression fails every attempt. Floors are never lowered by this.
|
||||||
- name: Performance floor gate
|
- 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
|
||||||
@@ -145,3 +153,27 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||||
|
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||||
|
excluded:
|
||||||
|
- .build
|
||||||
|
- .swiftpm
|
||||||
|
- .DerivedData
|
||||||
|
- DerivedData
|
||||||
|
- build
|
||||||
|
- localPackages/*/.build
|
||||||
|
|
||||||
|
disabled_rules:
|
||||||
|
- line_length
|
||||||
|
- type_name
|
||||||
|
- identifier_name
|
||||||
|
- statement_position
|
||||||
|
- implicit_optional_initialization
|
||||||
|
- force_try
|
||||||
|
- vertical_whitespace
|
||||||
|
- for_where
|
||||||
|
- control_statement
|
||||||
|
- void_function_in_ternary
|
||||||
|
- redundant_discardable_let # SwiftUI breaks without it
|
||||||
|
# To be enabled as we fix the issues
|
||||||
|
- trailing_whitespace
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- function_body_length
|
||||||
|
- function_parameter_count
|
||||||
|
- type_body_length
|
||||||
|
- file_length
|
||||||
|
- large_tuple
|
||||||
|
- force_cast
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
- nesting
|
||||||
+2
-2
@@ -13,9 +13,9 @@ let package = Package(
|
|||||||
.executable(
|
.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"),
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ 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")
|
||||||
}
|
}
|
||||||
@@ -67,11 +75,23 @@ 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,7 +232,13 @@ 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)
|
||||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||||
|
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||||
|
// report .offline. Report .nostrAvailable so the header shows the
|
||||||
|
// globe instead of a misleading "offline" tag.
|
||||||
|
let availability = conversationPeerID.isGeoDM
|
||||||
|
? .nostrAvailable
|
||||||
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
? nil
|
? nil
|
||||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum TrustLevel: String, Codable {
|
enum TrustLevel: String, Codable {
|
||||||
case unknown = "unknown"
|
case unknown
|
||||||
case casual = "casual"
|
case casual
|
||||||
case trusted = "trusted"
|
case trusted
|
||||||
case verified = "verified"
|
case verified
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Identity Cache
|
// MARK: - Identity Cache
|
||||||
|
|||||||
+915
-158
File diff suppressed because it is too large
Load Diff
@@ -11,33 +11,38 @@ 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 = "dm"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite
|
case favorite = "fav"
|
||||||
case unfavorite
|
case unfavorite = "unfav"
|
||||||
|
|
||||||
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, .who:
|
case .clear, .help, .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")
|
||||||
@@ -47,12 +52,14 @@ 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, .hug, .message, .slap, .who]
|
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||||
|
// The processor rejects favorites in geohash contexts, so only
|
||||||
|
// suggest them where they actually work: mesh.
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
return baseCommands
|
||||||
}
|
}
|
||||||
return baseCommands
|
return baseCommands + [.favorite, .unfavorite]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ 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 {
|
||||||
@@ -601,7 +602,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:
|
case .IK, .NK, .X:
|
||||||
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 {
|
||||||
@@ -904,6 +905,7 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -925,6 +927,10 @@ 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)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||||
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
|
||||||
awaitTorReady: { await TorManager.shared.awaitEgressReady() },
|
awaitTorReady: { await TorManager.shared.awaitReady() },
|
||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
|
|||||||
@@ -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: "nip44-v2".data(using: .utf8)!,
|
info: Data("nip44-v2".utf8),
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
@@ -61,11 +61,6 @@ struct NostrRelayManagerDependencies {
|
|||||||
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
|
||||||
var torEnforced: () -> Bool
|
var torEnforced: () -> Bool
|
||||||
var torIsReady: () -> Bool
|
var torIsReady: () -> Bool
|
||||||
/// Synchronous cached egress-gate check: `true` only while a positive Tor
|
|
||||||
/// egress verification is within its TTL (or Tor is not enforced). When
|
|
||||||
/// `false`, connections must be queued behind `awaitTorReady`, which runs
|
|
||||||
/// the async egress self-check.
|
|
||||||
var torEgressVerified: () -> Bool
|
|
||||||
var torIsForeground: () -> Bool
|
var torIsForeground: () -> Bool
|
||||||
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
|
||||||
var makeSession: () -> NostrRelaySessionProtocol
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
@@ -88,14 +83,10 @@ private extension NostrRelayManagerDependencies {
|
|||||||
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
|
||||||
torEnforced: { TorManager.shared.torEnforced },
|
torEnforced: { TorManager.shared.torEnforced },
|
||||||
torIsReady: { TorManager.shared.isReady },
|
torIsReady: { TorManager.shared.isReady },
|
||||||
torEgressVerified: { TorManager.shared.isEgressVerified },
|
|
||||||
torIsForeground: { TorManager.shared.isForeground() },
|
torIsForeground: { TorManager.shared.isForeground() },
|
||||||
awaitTorReady: { completion in
|
awaitTorReady: { completion in
|
||||||
Task.detached {
|
Task.detached {
|
||||||
// Require both Tor bootstrap AND a positive egress self-check
|
let ready = await TorManager.shared.awaitReady()
|
||||||
// so relay sockets never open unless traffic is proven to
|
|
||||||
// route through Tor (fail-closed).
|
|
||||||
let ready = await TorManager.shared.awaitEgressReady()
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
completion(ready)
|
completion(ready)
|
||||||
}
|
}
|
||||||
@@ -634,14 +625,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
/// Every path that opens a relay socket funnels through this check (initial
|
|
||||||
/// connect, reconnect backoff timers, subscription-triggered connects,
|
|
||||||
/// manual retry). It must hold connections back when Tor isn't bootstrapped
|
|
||||||
/// OR when the runtime egress self-check has no fresh positive verdict —
|
|
||||||
/// otherwise the already-bootstrapped path would open sockets without ever
|
|
||||||
/// running the egress canary.
|
|
||||||
private var shouldWaitForTorBeforeConnecting: Bool {
|
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||||
shouldUseTor && (!dependencies.torIsReady() || !dependencies.torEgressVerified())
|
shouldUseTor && !dependencies.torIsReady()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||||
@@ -689,20 +674,16 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard ready else {
|
guard ready else {
|
||||||
self.torReadyWaitAttempts += 1
|
self.torReadyWaitAttempts += 1
|
||||||
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
|
||||||
SecureLogger.warning("Tor not ready or egress unverified; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
|
||||||
self.queueConnectionsUntilTorReady(pending)
|
self.queueConnectionsUntilTorReady(pending)
|
||||||
} else {
|
} else {
|
||||||
// Still fail-closed (no network), but unblock any callers
|
// Still fail-closed (no network), but unblock any callers
|
||||||
// waiting on EOSE so the UI doesn't hang indefinitely.
|
// waiting on EOSE so the UI doesn't hang indefinitely.
|
||||||
// Queued subscriptions/sends are kept; a bounded-cadence
|
// Queued subscriptions/sends are kept and flush if a later
|
||||||
// retry (below) re-enters the gate so a transient failure
|
// trigger (e.g. app foreground) brings Tor up.
|
||||||
// (Tor stall, canary outage keeping the egress unverified)
|
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
|
||||||
// recovers automatically, and any later trigger (e.g. app
|
|
||||||
// foreground) also re-enters it.
|
|
||||||
SecureLogger.error("❌ Tor not ready or egress unverified after \(self.torReadyWaitAttempts) wait(s); relays stay closed (fail-closed), retrying in \(Int(TransportConfig.nostrTorGateRetrySeconds))s", category: .session)
|
|
||||||
self.torReadyWaitAttempts = 0
|
self.torReadyWaitAttempts = 0
|
||||||
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
|
||||||
self.scheduleTorGateRetry(pending)
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -712,24 +693,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After the Tor-gate wait attempts are exhausted, keep a low-frequency
|
|
||||||
/// retry alive so the gate re-opens without an external trigger once the
|
|
||||||
/// transient failure clears. Bounded cadence: one wait cycle per
|
|
||||||
/// `nostrTorGateRetrySeconds`; the egress verifier additionally throttles
|
|
||||||
/// actual canary probes to one per its `minRetryInterval`.
|
|
||||||
private func scheduleTorGateRetry(_ relayUrls: [String]) {
|
|
||||||
guard !relayUrls.isEmpty else { return }
|
|
||||||
let generation = connectionGeneration
|
|
||||||
dependencies.scheduleAfter(TransportConfig.nostrTorGateRetrySeconds) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
// Void after disconnect/reset: those paths bump the generation.
|
|
||||||
guard generation == self.connectionGeneration else { return }
|
|
||||||
self.queueConnectionsUntilTorReady(relayUrls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
|
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
|
||||||
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
|
||||||
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
/// would only be unblocked by Tor-readiness retry exhaustion (several
|
||||||
|
|||||||
@@ -132,4 +132,3 @@ private extension Data {
|
|||||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ 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.
|
||||||
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
@discardableResult
|
||||||
|
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(
|
||||||
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
|
|||||||
announcement = acceptance.announcement
|
announcement = acceptance.announcement
|
||||||
case .reject(.malformed):
|
case .reject(.malformed):
|
||||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return nil
|
||||||
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
|
return nil
|
||||||
case .reject(.selfAnnounce):
|
case .reject(.selfAnnounce):
|
||||||
return
|
return nil
|
||||||
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
@@ -210,5 +220,12 @@ 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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,35 @@ 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(
|
||||||
allowedLinks(
|
rawAllowed,
|
||||||
peripheralIDs: peripheralIDs,
|
|
||||||
centralIDs: centralIDs,
|
|
||||||
ingressLink: ingressLink,
|
|
||||||
excludedLinks: excludedLinks
|
|
||||||
),
|
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings
|
centralPeerBindings: centralPeerBindings
|
||||||
)
|
)
|
||||||
@@ -71,6 +93,42 @@ 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:
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,20 @@ 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()
|
||||||
|
|
||||||
@@ -888,6 +902,10 @@ 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)
|
||||||
@@ -1047,6 +1065,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -1305,7 +1326,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]) ?? [:]
|
||||||
@@ -1987,7 +2008,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]) ?? [:]
|
||||||
|
|
||||||
@@ -2468,7 +2489,142 @@ extension BLEService {
|
|||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Courier Store-and-Forward
|
||||||
|
|
||||||
|
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||||
|
/// the envelope to the given couriers for physical delivery. Returns false
|
||||||
|
/// when no courier is connected, the payload cannot be built, or sealing
|
||||||
|
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||||
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||||
|
let connected = couriers.filter { isPeerConnected($0) }
|
||||||
|
guard !connected.isEmpty,
|
||||||
|
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Data
|
||||||
|
do {
|
||||||
|
let now = Date()
|
||||||
|
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||||
|
let envelope = CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: recipientNoiseKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||||
|
ciphertext: sealed
|
||||||
|
)
|
||||||
|
guard let encoded = envelope.encode() else { return false }
|
||||||
|
payload = encoded
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
messageQueue.async { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
for courier in connected {
|
||||||
|
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: myPeerIDData,
|
||||||
|
recipientID: Data(hexString: peerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: messageTTL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||||
|
/// recipient (the rotating tag matches our static key → open and deliver)
|
||||||
|
/// or courier (a trusted peer is depositing mail for someone else → store).
|
||||||
|
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
// Directed packets only; envelopes addressed elsewhere ride the
|
||||||
|
// generic relay path untouched.
|
||||||
|
guard packet.recipientID == myPeerIDData else { return }
|
||||||
|
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
|
||||||
|
|
||||||
|
let myKey = noiseService.getStaticPublicKeyData()
|
||||||
|
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||||
|
openCourierEnvelope(envelope)
|
||||||
|
} else {
|
||||||
|
acceptCourierDeposit(envelope, from: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||||
|
do {
|
||||||
|
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||||
|
guard let typeRaw = typedPayload.first,
|
||||||
|
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||||
|
payloadType == .privateMessage else {
|
||||||
|
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Couriered mail arrives while the sender is absent, so the UI's
|
||||||
|
// block check can't resolve their fingerprint from a live session.
|
||||||
|
// Gate here, where the full static key is in hand.
|
||||||
|
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||||||
|
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mesh peer IDs are derived from Noise static keys, so the sender
|
||||||
|
// resolves to the same DM thread whether or not they're present.
|
||||||
|
let senderPeerID = PeerID(publicKey: senderStaticKey)
|
||||||
|
let payload = Data(typedPayload.dropFirst())
|
||||||
|
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||||
|
notifyUI { [weak self] in
|
||||||
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
|
peerID: senderPeerID,
|
||||||
|
type: payloadType,
|
||||||
|
payload: payload,
|
||||||
|
timestamp: Date()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Tag collision or stale key: not addressed to us after all.
|
||||||
|
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
|
||||||
|
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let store = courierStore
|
||||||
|
let policy = courierDepositPolicy
|
||||||
|
Task { @MainActor in
|
||||||
|
guard policy(depositorKey) else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if store.deposit(envelope, from: depositorKey) {
|
||||||
|
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
||||||
|
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
||||||
|
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
|
||||||
|
guard !envelopes.isEmpty else { return }
|
||||||
|
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
for envelope in envelopes {
|
||||||
|
guard let payload = envelope.encode() else { continue }
|
||||||
|
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||||
|
|
||||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||||
@@ -2700,7 +2856,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: "Encryption failed")))
|
self?.deliverTransportEvent(.messageDeliveryStatusUpdated(messageID: message.messageID, status: .failed(reason: String(localized: "content.delivery.reason.encryption_failed", comment: "Failure reason shown when a message could not be encrypted for the peer"))))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2953,13 +3109,15 @@ 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) {
|
||||||
@@ -3016,7 +3174,18 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
announceHandler.handle(packet, from: peerID)
|
let result = announceHandler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
// Courier handover: an announce is the moment we learn a peer's Noise
|
||||||
|
// static key, so check whether we're carrying mail addressed to them.
|
||||||
|
// Direct announces only: envelopes are removed from the store
|
||||||
|
// optimistically, so handover must ride an established link rather
|
||||||
|
// than a speculative multi-hop send toward a relayed announce.
|
||||||
|
guard !courierStore.isEmpty,
|
||||||
|
let result,
|
||||||
|
result.isVerified,
|
||||||
|
result.isDirectAnnounce else { return }
|
||||||
|
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the announce handler environment. All queue hops stay here so
|
/// Builds the announce handler environment. All queue hops stay here so
|
||||||
|
|||||||
@@ -105,11 +105,28 @@ 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)")
|
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local-only command reference, printed as a system message. The
|
||||||
|
/// suggestion panel hides once arguments are typed, and typos used to
|
||||||
|
/// dead-end in a bare "unknown command" — this is the way out.
|
||||||
|
static let helpText = """
|
||||||
|
commands:
|
||||||
|
/msg @name [message] — start a private chat
|
||||||
|
/who — list who's here
|
||||||
|
/clear — clear this chat
|
||||||
|
/hug @name — send a hug
|
||||||
|
/slap @name — slap with a large trout
|
||||||
|
/block @name · /unblock @name
|
||||||
|
/fav @name · /unfav @name — favorites (mesh only)
|
||||||
|
/help — this list
|
||||||
|
"""
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//
|
||||||
|
// CourierStore.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||||
|
///
|
||||||
|
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
||||||
|
/// never learns sender, recipient, or content. Strict quotas keep the device
|
||||||
|
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
||||||
|
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
||||||
|
/// retention policy. Carried mail is included in the panic wipe.
|
||||||
|
final class CourierStore {
|
||||||
|
struct StoredEnvelope: Codable, Equatable {
|
||||||
|
let recipientTag: Data
|
||||||
|
let expiry: UInt64
|
||||||
|
let ciphertext: Data
|
||||||
|
let depositorNoiseKey: Data
|
||||||
|
let storedAt: Date
|
||||||
|
|
||||||
|
var envelope: CourierEnvelope {
|
||||||
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Limits {
|
||||||
|
static let maxEnvelopes = 20
|
||||||
|
static let maxPerDepositor = 5
|
||||||
|
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||||
|
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = CourierStore()
|
||||||
|
|
||||||
|
/// Number of envelopes currently carried, published on the main thread
|
||||||
|
/// so the UI can show a "carrying mail" indicator.
|
||||||
|
@Published private(set) var carriedCount: Int = 0
|
||||||
|
|
||||||
|
/// Fast path so hot code (announce handling) can skip tag computation.
|
||||||
|
var isEmpty: Bool {
|
||||||
|
queue.sync { envelopes.isEmpty }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var envelopes: [StoredEnvelope] = []
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||||
|
private let fileURL: URL?
|
||||||
|
private let now: () -> Date
|
||||||
|
|
||||||
|
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||||
|
/// when `persistsToDisk` is false.
|
||||||
|
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||||
|
self.now = now
|
||||||
|
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||||
|
loadFromDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Depositing (courier side)
|
||||||
|
|
||||||
|
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||||
|
/// validity checks reject it. Trust policy (mutual favorite) is the
|
||||||
|
/// caller's responsibility; this store only enforces resource bounds.
|
||||||
|
@discardableResult
|
||||||
|
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
|
||||||
|
let date = now()
|
||||||
|
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||||
|
!envelope.ciphertext.isEmpty,
|
||||||
|
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
|
||||||
|
!envelope.isExpired(at: date) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Reject expiries beyond the policy lifetime so depositors can't pin
|
||||||
|
// storage longer than the outbox would retain the message itself.
|
||||||
|
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
|
||||||
|
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
|
||||||
|
// Identical ciphertext is the same envelope; accept idempotently.
|
||||||
|
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if envelopes.count >= Limits.maxEnvelopes {
|
||||||
|
// Oldest-first eviction, matching outbox overflow behavior.
|
||||||
|
let evicted = envelopes.removeFirst()
|
||||||
|
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelopes.append(StoredEnvelope(
|
||||||
|
recipientTag: envelope.recipientTag,
|
||||||
|
expiry: envelope.expiry,
|
||||||
|
ciphertext: envelope.ciphertext,
|
||||||
|
depositorNoiseKey: depositorNoiseKey,
|
||||||
|
storedAt: date
|
||||||
|
))
|
||||||
|
persistLocked()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handover (on encountering a peer)
|
||||||
|
|
||||||
|
/// Remove and return all envelopes addressed to the given peer, matching
|
||||||
|
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||||
|
/// optimistically: handover happens over a live link, and the depositor's
|
||||||
|
/// outbox still retains the original for direct delivery.
|
||||||
|
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||||
|
let date = now()
|
||||||
|
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||||
|
guard !matched.isEmpty else { return [] }
|
||||||
|
envelopes.removeAll { stored in matched.contains(stored) }
|
||||||
|
persistLocked()
|
||||||
|
return matched.map(\.envelope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Maintenance
|
||||||
|
|
||||||
|
func pruneExpired() {
|
||||||
|
let date = now()
|
||||||
|
queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
persistLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic wipe: drop all carried mail from memory and disk.
|
||||||
|
func wipe() {
|
||||||
|
queue.sync {
|
||||||
|
envelopes.removeAll()
|
||||||
|
if let fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
publishCountLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals (call only on `queue`)
|
||||||
|
|
||||||
|
private func pruneExpiredLocked(at date: Date) {
|
||||||
|
let before = envelopes.count
|
||||||
|
envelopes.removeAll { $0.envelope.isExpired(at: date) }
|
||||||
|
if envelopes.count != before {
|
||||||
|
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func publishCountLocked() {
|
||||||
|
let count = envelopes.count
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.carriedCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistLocked() {
|
||||||
|
publishCountLocked()
|
||||||
|
guard let fileURL else { return }
|
||||||
|
do {
|
||||||
|
if envelopes.isEmpty {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(envelopes)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(.completeFileProtection)
|
||||||
|
#endif
|
||||||
|
try data.write(to: fileURL, options: options)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadFromDisk() {
|
||||||
|
guard let fileURL else { return }
|
||||||
|
queue.sync {
|
||||||
|
guard let data = try? Data(contentsOf: fileURL),
|
||||||
|
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
envelopes = stored
|
||||||
|
pruneExpiredLocked(at: now())
|
||||||
|
publishCountLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultFileURL() -> URL? {
|
||||||
|
guard let base = try? FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
) else { return nil }
|
||||||
|
return base
|
||||||
|
.appendingPathComponent("courier", isDirectory: true)
|
||||||
|
.appendingPathComponent("envelopes.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,33 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Trust and identity lookups the router needs to pick couriers. Backed by
|
||||||
|
/// the favorites store in production; injectable for tests.
|
||||||
|
struct CourierDirectory {
|
||||||
|
/// Noise static key for a peer we can address while they're offline.
|
||||||
|
var noiseKey: (PeerID) -> Data?
|
||||||
|
/// Whether a peer (by Noise static key) may carry our mail.
|
||||||
|
var isTrustedCourier: (Data) -> Bool
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func favoritesBacked() -> CourierDirectory {
|
||||||
|
CourierDirectory(
|
||||||
|
noiseKey: { peerID in
|
||||||
|
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||||
|
},
|
||||||
|
isTrustedCourier: { noiseKey in
|
||||||
|
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
|
private let courierDirectory: CourierDirectory
|
||||||
|
|
||||||
/// Invoked whenever a retained private message is dropped without a
|
/// Invoked whenever a retained private message is dropped without a
|
||||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||||
@@ -14,6 +36,12 @@ 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
|
||||||
@@ -31,10 +59,17 @@ 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(transports: [Transport], now: @escaping () -> Date = Date.init) {
|
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(
|
||||||
@@ -51,7 +86,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,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
note.userInfo?["isKeyUpdate"] is 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)
|
||||||
@@ -94,6 +129,33 @@ final class MessageRouter {
|
|||||||
unsent.sendAttempts = 0
|
unsent.sendAttempts = 0
|
||||||
enqueue(unsent, for: peerID)
|
enqueue(unsent, for: peerID)
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||||
|
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last resort when no transport can reach the peer: seal the message to
|
||||||
|
/// their known static key and hand it to connected mutual favorites who
|
||||||
|
/// may physically encounter them. The queued copy above stays retained,
|
||||||
|
/// so direct delivery still wins if the peer reappears first (receivers
|
||||||
|
/// dedup by message ID).
|
||||||
|
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||||
|
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||||
|
for transport in transports {
|
||||||
|
let couriers = transport.currentPeerSnapshots()
|
||||||
|
.filter { snapshot in
|
||||||
|
guard snapshot.isConnected,
|
||||||
|
let key = snapshot.noisePublicKey,
|
||||||
|
key != recipientKey else { return false }
|
||||||
|
return courierDirectory.isTrustedCourier(key)
|
||||||
|
}
|
||||||
|
.prefix(Self.maxCouriersPerMessage)
|
||||||
|
.map(\.peerID)
|
||||||
|
guard !couriers.isEmpty else { continue }
|
||||||
|
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
|
||||||
|
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
onMessageCarried?(messageID, peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,6 +369,49 @@ 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() {
|
||||||
@@ -428,7 +471,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 = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
let context = Data("bitchat-announce-v1".utf8)
|
||||||
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)
|
||||||
@@ -444,7 +487,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 = nickname.data(using: .utf8) ?? Data()
|
let nickData = Data(nickname.utf8)
|
||||||
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
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||||
|
|
||||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
|
|||||||
@@ -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, error in
|
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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:
|
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ 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)
|
||||||
@@ -120,6 +126,7 @@ 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) {}
|
||||||
|
|||||||
@@ -171,10 +171,6 @@ enum TransportConfig {
|
|||||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||||
// After Tor-gate wait attempts are exhausted (Tor never ready, or egress
|
|
||||||
// self-check unverified), retry the whole gate at this bounded cadence so
|
|
||||||
// a transient failure (e.g. canary outage) recovers without user action.
|
|
||||||
static let nostrTorGateRetrySeconds: TimeInterval = 30.0
|
|
||||||
static let nostrPendingSendQueueCap: Int = 200
|
static let nostrPendingSendQueueCap: Int = 200
|
||||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||||
|
|||||||
@@ -257,7 +257,29 @@ 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 {
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ 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,4 +27,3 @@ struct PeerDisplayNameResolver {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -355,9 +355,10 @@ 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 .partiallyDelivered: return 4
|
case .carried: return 4
|
||||||
case .delivered: return 5
|
case .partiallyDelivered: return 5
|
||||||
case .read: return 6
|
case .delivered: 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: "Voice note too large")
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||||
}
|
}
|
||||||
} catch {
|
} 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: "Failed to send voice note")
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ final class ChatNostrCoordinator {
|
|||||||
key: Data?
|
key: Data?
|
||||||
) {
|
) {
|
||||||
guard let context else { return }
|
guard let context else { return }
|
||||||
if let _ = key {
|
if key != nil {
|
||||||
if let identity = context.currentNostrIdentity() {
|
if let identity = context.currentNostrIdentity() {
|
||||||
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
|
||||||
}
|
}
|
||||||
@@ -84,7 +84,7 @@ final class ChatNostrCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||||
if let _ = key {
|
if key != nil {
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -988,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
|
||||||
|
// command, these resolve and persist the block by the peer's stable
|
||||||
|
// fingerprint (derived from `peerID`), so the exact tapped peer is
|
||||||
|
// (un)blocked — unambiguous across nickname collisions and functional for
|
||||||
|
// offline peers that can no longer be resolved through the mesh service.
|
||||||
|
@MainActor
|
||||||
|
func blockMeshPeer(peerID: PeerID, displayName: String) {
|
||||||
|
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func unblockMeshPeer(peerID: PeerID, displayName: String) {
|
||||||
|
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
|
||||||
|
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
|
||||||
|
addCommandOutput(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
|
||||||
|
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addCommandOutput(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
|
||||||
|
comment: "System message shown when a mesh peer is blocked or unblocked"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1189,9 @@ 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
|
||||||
@@ -1435,7 +1480,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 /nick, /msg, /who, /slap, /clear, /help
|
/// - Note: Supports commands like /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)
|
||||||
@@ -1443,16 +1488,29 @@ 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 {
|
||||||
addSystemMessage(msg)
|
addCommandOutput(msg)
|
||||||
}
|
}
|
||||||
case .error(let message):
|
case .error(let message):
|
||||||
addSystemMessage(message)
|
addCommandOutput(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,11 +100,28 @@ private extension ChatViewModelBootstrapper {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
viewModel.conversations.setDeliveryStatus(
|
viewModel.conversations.setDeliveryStatus(
|
||||||
.failed(reason: "Not delivered"),
|
.failed(reason: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||||
forMessageID: messageID
|
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)
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ struct AppInfoView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Legend {
|
||||||
|
static let title: LocalizedStringKey = "app_info.legend.title"
|
||||||
|
/// Every glyph the peer lists and headers use, in one place —
|
||||||
|
/// nothing else in the app defines them.
|
||||||
|
static let items: [(icon: String, text: LocalizedStringKey)] = [
|
||||||
|
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
|
||||||
|
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
|
||||||
|
("globe", "app_info.legend.nostr"),
|
||||||
|
("person", "app_info.legend.offline"),
|
||||||
|
("mappin.and.ellipse", "app_info.legend.location_nearby"),
|
||||||
|
("face.dashed", "app_info.legend.teleported"),
|
||||||
|
("lock.fill", "app_info.legend.encrypted"),
|
||||||
|
("lock.slash", "app_info.legend.encryption_failed"),
|
||||||
|
("checkmark.seal.fill", "app_info.legend.verified"),
|
||||||
|
("star.fill", "app_info.legend.favorite"),
|
||||||
|
("envelope.fill", "app_info.legend.unread"),
|
||||||
|
("nosign", "app_info.legend.blocked")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
enum Privacy {
|
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(
|
||||||
@@ -118,14 +138,8 @@ struct AppInfoView: View {
|
|||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { dismiss() }) {
|
SheetCloseButton { dismiss() }
|
||||||
Image(systemName: "xmark")
|
.foregroundColor(textColor)
|
||||||
.bitchatFont(size: 13, weight: .semibold)
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("app_info.close")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +216,28 @@ struct AppInfoView: View {
|
|||||||
FeatureRow(info: Strings.Features.mentions)
|
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,29 +14,46 @@ 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("/") && !messageText.contains(" ") else { return [] }
|
guard messageText.hasPrefix("/") else { return [] }
|
||||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
|
||||||
|
// While arguments are being typed, keep the matched command's usage
|
||||||
|
// row visible instead of vanishing at the first space.
|
||||||
|
if let typed = typedCommandAlias {
|
||||||
|
return commands.filter { $0.alias == typed && $0.placeholder != nil }
|
||||||
|
}
|
||||||
|
return commands.filter { command in
|
||||||
command.alias.starts(with: messageText.lowercased())
|
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,6 +9,47 @@
|
|||||||
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
|
||||||
@@ -19,56 +60,34 @@ struct DeliveryStatusView: View {
|
|||||||
|
|
||||||
private var secondaryTextColor: Color { palette.secondary }
|
private var secondaryTextColor: Color { palette.secondary }
|
||||||
|
|
||||||
private enum Strings {
|
|
||||||
static func delivered(to nickname: String) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
|
||||||
locale: .current,
|
|
||||||
nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func read(by nickname: String) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
|
||||||
locale: .current,
|
|
||||||
nickname
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func failed(_ reason: String) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
|
||||||
locale: .current,
|
|
||||||
reason
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
|
||||||
locale: .current,
|
|
||||||
reached,
|
|
||||||
total
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Body
|
// MARK: - Body
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
statusGlyph
|
||||||
|
.help(status.bitchatDescription)
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(status.bitchatDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusGlyph: 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 .delivered(let nickname, _):
|
case .carried:
|
||||||
|
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))
|
||||||
@@ -76,24 +95,22 @@ 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:
|
||||||
case .read(let nickname, _):
|
// Filled variant so read vs delivered is legible without color.
|
||||||
HStack(spacing: -2) {
|
HStack(spacing: 0) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
.font(.bitchatSystem(size: 9, 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")
|
||||||
@@ -102,7 +119,6 @@ struct DeliveryStatusView: View {
|
|||||||
.bitchatFont(size: 10)
|
.bitchatFont(size: 10)
|
||||||
}
|
}
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
.help(Strings.deliveredToMembers(reached, total))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +127,7 @@ 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 {
|
||||||
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
|
||||||
}
|
}
|
||||||
private var border: Color { fgColor.opacity(0.25) }
|
private var border: Color { fgColor.opacity(0.25) }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
//
|
||||||
|
// SheetCloseButton.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The close "X" every sheet and header shares. One glyph size and weight
|
||||||
|
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
|
||||||
|
/// so existing header metrics don't move, and a hit target extended to 44pt
|
||||||
|
/// per platform guidelines. Tint comes from the environment, so callers keep
|
||||||
|
/// their own foreground color.
|
||||||
|
struct SheetCloseButton: View {
|
||||||
|
let action: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.bitchatFont(size: 13, weight: .semibold)
|
||||||
|
.frame(width: 32, height: 32)
|
||||||
|
.contentShape(Rectangle().inset(by: -6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import BitFoundation
|
|||||||
struct TextMessageView: View {
|
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
|
||||||
@@ -24,6 +25,7 @@ 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
|
||||||
@@ -35,19 +37,59 @@ 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()
|
||||||
HStack(alignment: .top, spacing: 0) {
|
// Baseline alignment keeps the lock and delivery glyphs on the
|
||||||
|
// first text line; a fixed top padding left the lock's solid body
|
||||||
|
// hanging below the line's visual center.
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
let 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
|
// 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),
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
let status = deliveryStatus {
|
let status = deliveryStatus {
|
||||||
DeliveryStatusView(status: status)
|
Button {
|
||||||
.padding(.leading, 4)
|
showDeliveryDetail.toggle()
|
||||||
|
} label: {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure reasons stay visible without a tap; other statuses
|
||||||
|
// reveal on demand.
|
||||||
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
|
let status = deliveryStatus {
|
||||||
|
if case .failed = status {
|
||||||
|
Text(verbatim: status.bitchatDescription)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(Color.red.opacity(0.9))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, 2)
|
||||||
|
} else if showDeliveryDetail {
|
||||||
|
Text(verbatim: status.bitchatDescription)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +102,7 @@ struct TextMessageView: View {
|
|||||||
else { expandedMessageIDs.insert(message.id) }
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
}
|
}
|
||||||
.bitchatFont(size: 11, weight: .medium)
|
.bitchatFont(size: 11, weight: .medium)
|
||||||
.foregroundColor(Color.blue)
|
.foregroundColor(palette.accentBlue)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +120,12 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ 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,10 +60,8 @@ struct ContentComposerView: View {
|
|||||||
TextField(
|
TextField(
|
||||||
"",
|
"",
|
||||||
text: $messageText,
|
text: $messageText,
|
||||||
prompt: Text(
|
prompt: Text(placeholderText)
|
||||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
.foregroundColor(palette.secondary.opacity(0.6))
|
||||||
)
|
|
||||||
.foregroundColor(palette.secondary.opacity(0.6))
|
|
||||||
)
|
)
|
||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.bitchatFont(size: 15)
|
.bitchatFont(size: 15)
|
||||||
@@ -110,6 +108,33 @@ 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")
|
||||||
@@ -158,7 +183,19 @@ private extension ContentComposerView {
|
|||||||
imagePickerSourceType = .camera
|
imagePickerSourceType = .camera
|
||||||
showImagePicker = true
|
showImagePicker = true
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Tap for library, long press for camera")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||||
|
)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||||
|
)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
// The long-press → camera path is unreachable for VoiceOver users;
|
||||||
|
// mirror it as a named action.
|
||||||
|
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||||
|
imagePickerSourceType = .camera
|
||||||
|
showImagePicker = true
|
||||||
|
}
|
||||||
#else
|
#else
|
||||||
Button(action: { showMacImagePicker = true }) {
|
Button(action: { showMacImagePicker = true }) {
|
||||||
Image(systemName: "photo.circle.fill")
|
Image(systemName: "photo.circle.fill")
|
||||||
@@ -167,7 +204,9 @@ private extension ContentComposerView {
|
|||||||
.frame(width: 36, height: 36)
|
.frame(width: 36, height: 36)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Choose photo")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||||
|
)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +248,27 @@ private extension ContentComposerView {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.accessibilityLabel("Hold to record a voice note")
|
.accessibilityLabel(
|
||||||
|
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||||
|
)
|
||||||
|
.accessibilityValue(
|
||||||
|
voiceRecordingVM.state.isActive
|
||||||
|
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||||
|
)
|
||||||
|
.accessibilityAddTraits(.isButton)
|
||||||
|
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||||
|
// give it a start/stop toggle as the default action.
|
||||||
|
.accessibilityAction {
|
||||||
|
if voiceRecordingVM.state.isActive {
|
||||||
|
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
|
||||||
|
} else {
|
||||||
|
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendButtonView(enabled: Bool) -> some View {
|
func sendButtonView(enabled: Bool) -> some View {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ 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/")
|
||||||
@@ -33,6 +36,16 @@ 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: "@")
|
||||||
@@ -78,6 +91,23 @@ 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")
|
||||||
@@ -183,6 +213,13 @@ 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) {
|
||||||
@@ -190,8 +227,16 @@ 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)
|
||||||
@@ -266,14 +311,25 @@ 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 : Color.secondary)
|
return (count, count > 0 ? palette.locationAccent : palette.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 : Color.secondary
|
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
|
||||||
return (peerListModel.reachableMeshPeerCount, color)
|
return (peerListModel.reachableMeshPeerCount, color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,16 +349,10 @@ private struct ContentLocationNotesUnavailableView: View {
|
|||||||
Text("content.notes.title")
|
Text("content.notes.title")
|
||||||
.bitchatFont(size: 16, weight: .bold)
|
.bitchatFont(size: 16, weight: .bold)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: { showLocationNotes = false }) {
|
SheetCloseButton { showLocationNotes = false }
|
||||||
Image(systemName: "xmark")
|
.foregroundColor(palette.primary)
|
||||||
.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(height: headerHeight)
|
.frame(minHeight: headerHeight)
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.themedChromePanel(edge: .top)
|
.themedChromePanel(edge: .top)
|
||||||
Text("content.notes.location_unavailable")
|
Text("content.notes.location_unavailable")
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ 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
|
||||||
@@ -159,24 +160,23 @@ 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")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Button(action: {
|
SheetCloseButton {
|
||||||
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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
} else {
|
} else {
|
||||||
Text(activeText)
|
Text(activeText)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -231,6 +231,13 @@ 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -333,6 +340,9 @@ 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(
|
||||||
@@ -346,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|
||||||
Button(action: {
|
SheetCloseButton {
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
.frame(height: headerHeight)
|
// minHeight so scaled text at accessibility sizes grows the
|
||||||
|
// bar instead of clipping inside it.
|
||||||
|
.frame(minHeight: headerHeight)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
.padding(.bottom, 12)
|
.padding(.bottom, 12)
|
||||||
.themedSurface()
|
.modifier(PrivateHeaderChrome())
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageListView(
|
MessageListView(
|
||||||
@@ -385,6 +391,8 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
privacyCaption
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
ContentComposerView(
|
ContentComposerView(
|
||||||
messageText: $messageText,
|
messageText: $messageText,
|
||||||
@@ -421,6 +429,69 @@ private struct ContentPrivateChatSheetView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persistent one-line reminder that this composer feeds a private
|
||||||
|
/// conversation — the DM sheet otherwise renders identically to the
|
||||||
|
/// public timeline. Claims end-to-end encryption only once the session
|
||||||
|
/// is actually secured.
|
||||||
|
private var privacyCaption: some View {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.bitchatSystem(size: 9))
|
||||||
|
// Optical centering: lock.fill's ink is bottom-heavy, so
|
||||||
|
// geometric centering reads low next to the caption text.
|
||||||
|
.offset(y: -1)
|
||||||
|
Text(verbatim: privacyCaptionText)
|
||||||
|
.bitchatFont(size: 11, weight: .medium)
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.orange)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
// The orange text is signature enough; a tinted band here reads as a
|
||||||
|
// stray strip against the untinted composer chrome below it, so the
|
||||||
|
// caption sits on the same surface as the rest of the bottom chrome.
|
||||||
|
.themedSurface()
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var privacyCaptionText: String {
|
||||||
|
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||||
|
// even though they carry no Noise session status. Mesh DMs earn the
|
||||||
|
// "encrypted" claim only once the Noise handshake has secured.
|
||||||
|
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||||
|
let noiseSecured: Bool = {
|
||||||
|
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||||
|
case .noiseSecured, .noiseVerified: return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if isGeoDM || noiseSecured {
|
||||||
|
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||||
|
}
|
||||||
|
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
|
||||||
|
/// over an opaque themed surface. Glass gets the same floating panel as the
|
||||||
|
/// main header instead: an orange wash over the backdrop gradient reads as a
|
||||||
|
/// muddy gray-beige band, and the DM signature is already carried by the
|
||||||
|
/// orange lock, caption, and composer accents.
|
||||||
|
private struct PrivateHeaderChrome: ViewModifier {
|
||||||
|
@Environment(\.appTheme) private var theme
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
if theme.usesGlassChrome {
|
||||||
|
content.themedChromePanel(edge: .top)
|
||||||
|
} else {
|
||||||
|
// Orange tint before themedSurface so it layers in front of the
|
||||||
|
// opaque themed background rather than behind it.
|
||||||
|
content
|
||||||
|
.background(Color.orange.opacity(0.06))
|
||||||
|
.themedSurface()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ContentPrivateHeaderInfoButton: View {
|
private struct ContentPrivateHeaderInfoButton: View {
|
||||||
@@ -452,17 +523,30 @@ 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:
|
||||||
EmptyView()
|
// Absence of a glyph was the only offline signal; say it.
|
||||||
|
Text("mesh_peers.state.offline")
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(headerState.displayName)
|
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
|
||||||
@@ -489,6 +573,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(height: headerHeight)
|
.frame(minHeight: headerHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,8 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { dismiss() }) {
|
SheetCloseButton { dismiss() }
|
||||||
Image(systemName: "xmark")
|
.foregroundColor(textColor)
|
||||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
|
||||||
}
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
|
||||||
@@ -83,7 +80,7 @@ struct FingerprintView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.background(Color.gray.opacity(0.1))
|
.background(palette.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
|
|
||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
@@ -101,7 +98,7 @@ struct FingerprintView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.gray.opacity(0.1))
|
.background(palette.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(Strings.copy) {
|
Button(Strings.copy) {
|
||||||
@@ -135,7 +132,7 @@ struct FingerprintView: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.padding()
|
.padding()
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.background(Color.gray.opacity(0.1))
|
.background(palette.secondary.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button(Strings.copy) {
|
Button(Strings.copy) {
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ struct GeohashPeopleList: View {
|
|||||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
static let 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 {
|
||||||
@@ -46,7 +53,12 @@ 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).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon)
|
||||||
|
// Size 10 to match the mesh rows' leading glyphs —
|
||||||
|
// both lists share the sidebar.
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(rowColor)
|
||||||
|
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||||
|
|
||||||
let (base, suffix) = person.displayName.splitSuffix()
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -54,6 +66,8 @@ 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)
|
||||||
@@ -105,6 +119,27 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityDescription(for: person))
|
||||||
|
.accessibilityAddTraits(person.isMe ? [] : .isButton)
|
||||||
|
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
|
||||||
|
.accessibilityActions {
|
||||||
|
if !person.isMe {
|
||||||
|
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
|
||||||
|
if person.isBlocked {
|
||||||
|
peerListModel.unblockGeohashUser(
|
||||||
|
pubkeyHexLowercased: person.id,
|
||||||
|
displayName: person.displayName
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
peerListModel.blockGeohashUser(
|
||||||
|
pubkeyHexLowercased: person.id,
|
||||||
|
displayName: person.displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
@@ -119,4 +154,13 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One spoken sentence per row: name, presence type, and block state.
|
||||||
|
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
|
||||||
|
var parts: [String] = [person.displayName]
|
||||||
|
if person.isMe { parts.append(Strings.youState) }
|
||||||
|
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||||
|
if person.isBlocked { parts.append(Strings.blockedState) }
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
|
|||||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
static let 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")
|
||||||
@@ -103,7 +106,7 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
Text(Strings.description)
|
Text(Strings.description)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
switch locationChannelsModel.permissionState {
|
switch locationChannelsModel.permissionState {
|
||||||
@@ -122,7 +125,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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
Button(action: { isPresented = false }) {
|
SheetCloseButton { 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 {
|
||||||
@@ -210,7 +207,10 @@ 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(.secondary)
|
.foregroundColor(palette.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(Color.secondary.opacity(0.12))
|
.background(palette.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(.secondary)
|
.foregroundColor(palette.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,7 +357,10 @@ 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 {
|
||||||
@@ -399,6 +402,8 @@ 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) {
|
||||||
@@ -409,17 +414,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 ?? Color.primary)
|
.foregroundColor(titleColor ?? palette.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(.secondary)
|
.foregroundColor(palette.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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
@@ -434,6 +439,19 @@ 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]"
|
||||||
@@ -477,16 +495,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(.primary)
|
.foregroundColor(palette.primary)
|
||||||
Text(Strings.torSubtitle)
|
Text(Strings.torSubtitle)
|
||||||
.bitchatFont(size: 11)
|
.bitchatFont(size: 11)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.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(Color.secondary.opacity(0.12))
|
.background(palette.secondary.opacity(0.12))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ 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"
|
||||||
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var closeButton: some View {
|
private var closeButton: some View {
|
||||||
Button(action: { dismiss() }) {
|
SheetCloseButton { 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 {
|
||||||
@@ -126,12 +119,12 @@ struct LocationNotesView: View {
|
|||||||
}
|
}
|
||||||
Text(Strings.description)
|
Text(Strings.description)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -180,7 +173,7 @@ struct LocationNotesView: View {
|
|||||||
if !ts.isEmpty {
|
if !ts.isEmpty {
|
||||||
Text(ts)
|
Text(ts)
|
||||||
.bitchatFont(size: 11)
|
.bitchatFont(size: 11)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -197,7 +190,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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
Button(Strings.retry) { manager.refresh() }
|
Button(Strings.retry) { manager.refresh() }
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -210,7 +203,7 @@ struct LocationNotesView: View {
|
|||||||
ProgressView()
|
ProgressView()
|
||||||
Text(Strings.loadingNotes)
|
Text(Strings.loadingNotes)
|
||||||
.bitchatFont(size: 12)
|
.bitchatFont(size: 12)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -222,7 +215,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(.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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
|
||||||
@@ -20,6 +21,25 @@ 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,
|
||||||
@@ -69,20 +89,32 @@ 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(
|
||||||
Image(systemName: "eye.slash.fill")
|
VStack(spacing: 6) {
|
||||||
.font(.bitchatSystem(size: 24, weight: .semibold))
|
Image(systemName: "eye.slash.fill")
|
||||||
.foregroundColor(.white.opacity(0.85))
|
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||||
|
Text(verbatim: Strings.tapToReveal)
|
||||||
|
// Themed: monospaced under matrix,
|
||||||
|
// system under liquid glass.
|
||||||
|
.bitchatFont(size: 12, weight: .medium)
|
||||||
|
}
|
||||||
|
.foregroundColor(.white.opacity(0.85))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||||
.fill(Color.gray.opacity(0.2))
|
.fill(palette.secondary.opacity(0.2))
|
||||||
.frame(height: 200)
|
.frame(height: 200)
|
||||||
.overlay(
|
.overlay {
|
||||||
ProgressView()
|
if loadFailed {
|
||||||
.progressViewStyle(.circular)
|
Image(systemName: "photo")
|
||||||
)
|
.font(.bitchatSystem(size: 24, weight: .semibold))
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(.circular)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
if let onCancel = onCancel, isSending {
|
||||||
@@ -95,6 +127,7 @@ struct BlockRevealImageView: View {
|
|||||||
.padding(8)
|
.padding(8)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(Strings.cancelSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -106,30 +139,105 @@ struct BlockRevealImageView: View {
|
|||||||
loadImage()
|
loadImage()
|
||||||
}
|
}
|
||||||
.gesture(mainGesture)
|
.gesture(mainGesture)
|
||||||
}
|
.contextMenu {
|
||||||
|
if isSending {
|
||||||
private func loadImage() {
|
cancelSendAction
|
||||||
DispatchQueue.global(qos: .userInitiated).async {
|
} else {
|
||||||
#if os(iOS)
|
imageActions
|
||||||
guard let image = UIImage(contentsOfFile: url.path) else { return }
|
}
|
||||||
#else
|
}
|
||||||
guard let image = NSImage(contentsOf: url) else { return }
|
.confirmationDialog(
|
||||||
#endif
|
Strings.deleteConfirmTitle,
|
||||||
let ratio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
isPresented: $showDeleteConfirmation,
|
||||||
DispatchQueue.main.async {
|
titleVisibility: .visible
|
||||||
self.platformImage = image
|
) {
|
||||||
self.aspectRatio = ratio
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var mainGesture: some Gesture {
|
@ViewBuilder
|
||||||
let doubleTap = TapGesture(count: 2).onEnded {
|
private var cancelSendAction: some View {
|
||||||
guard !isSending else { return }
|
if let onCancel {
|
||||||
onDelete?()
|
Button(Strings.cancelSend, action: onCancel)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var imageActions: some View {
|
||||||
|
// Open/reveal/hide would act on a file that failed to load, so only
|
||||||
|
// offer delete (when available) to let users clean up the attachment.
|
||||||
|
if !loadFailed {
|
||||||
|
if isBlurred {
|
||||||
|
Button(Strings.reveal) {
|
||||||
|
withAnimation(.easeOut(duration: 0.2)) { isBlurred = false }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(Strings.open) { onOpen?() }
|
||||||
|
Button(Strings.hide) {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) { isBlurred = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if onDelete != nil {
|
||||||
|
Button(Strings.delete, role: .destructive) { showDeleteConfirmation = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityLabelText: String {
|
||||||
|
if isSending { return Strings.sendingImage }
|
||||||
|
if loadFailed { return Strings.unavailableImage }
|
||||||
|
return isBlurred ? Strings.hiddenImage : Strings.revealedImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private var accessibilityHintText: String {
|
||||||
|
if isSending || loadFailed { return "" }
|
||||||
|
return isBlurred ? Strings.revealHint : Strings.openHint
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadImage() {
|
||||||
|
loadFailed = false
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
#if os(iOS)
|
||||||
|
let image = UIImage(contentsOfFile: url.path)
|
||||||
|
#else
|
||||||
|
let image = NSImage(contentsOf: url)
|
||||||
|
#endif
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let image else {
|
||||||
|
self.loadFailed = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.platformImage = image
|
||||||
|
self.aspectRatio = image.size.height > 0 ? image.size.width / image.size.height : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Double-tap used to permanently delete the image — the most ingrained
|
||||||
|
// photo gesture on mobile, racing the reveal tap, with no confirmation
|
||||||
|
// and no way to get the file back. Delete now lives in the context menu
|
||||||
|
// behind a confirmation; taps only reveal and open.
|
||||||
|
private var mainGesture: some Gesture {
|
||||||
let singleTap = TapGesture().onEnded {
|
let singleTap = TapGesture().onEnded {
|
||||||
guard !isSending else { return }
|
guard !isSending, !loadFailed else { return }
|
||||||
if isBlurred {
|
if isBlurred {
|
||||||
withAnimation(.easeOut(duration: 0.2)) {
|
withAnimation(.easeOut(duration: 0.2)) {
|
||||||
isBlurred = false
|
isBlurred = false
|
||||||
@@ -139,7 +247,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 else { return }
|
guard !isSending, !loadFailed 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 }
|
||||||
@@ -149,7 +257,7 @@ struct BlockRevealImageView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return doubleTap.exclusively(before: singleTap).simultaneously(with: swipe)
|
return singleTap.simultaneously(with: swipe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import BitFoundation
|
|||||||
struct MediaMessageView: View {
|
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
|
||||||
@@ -20,6 +21,7 @@ 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?
|
||||||
|
|
||||||
@@ -35,46 +37,93 @@ 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
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
// Baseline alignment (via the header text inside the VStack) keeps the
|
||||||
HStack(alignment: .center, spacing: 4) {
|
// lock on the header line; a fixed top padding left its solid body
|
||||||
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
// hanging below the line's visual center.
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
if message.isPrivate {
|
||||||
|
Image(systemName: "lock.fill")
|
||||||
|
.font(.bitchatSystem(size: 8))
|
||||||
|
.foregroundColor(Color.orange.opacity(0.75))
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(alignment: .center, spacing: 4) {
|
||||||
|
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
// Delivery status indicator for private messages. Tappable:
|
||||||
|
// .help() tooltips only exist on macOS, so iOS users get the
|
||||||
|
// explanation as a caption under the row instead.
|
||||||
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
|
let status = deliveryStatus {
|
||||||
|
Button {
|
||||||
|
showDeliveryDetail.toggle()
|
||||||
|
} label: {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityHint(
|
||||||
|
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failure reasons stay visible without a tap; other statuses
|
||||||
|
// reveal on demand.
|
||||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||||
let status = deliveryStatus {
|
let status = deliveryStatus {
|
||||||
DeliveryStatusView(status: status)
|
if case .failed = status {
|
||||||
.padding(.leading, 4)
|
Text(verbatim: status.bitchatDescription)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(Color.red.opacity(0.9))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
} else if showDeliveryDetail {
|
||||||
|
Text(verbatim: status.bitchatDescription)
|
||||||
|
.bitchatFont(size: 11)
|
||||||
|
.foregroundColor(palette.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
||||||
@@ -90,7 +139,7 @@ struct MediaMessageView: View {
|
|||||||
isSending = true
|
isSending = true
|
||||||
progress = Double(reached) / Double(total)
|
progress = Double(reached) / Double(total)
|
||||||
}
|
}
|
||||||
case .sent, .read, .delivered, .failed:
|
case .sent, .carried, .read, .delivered, .failed:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ struct VoiceNoteView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color {
|
||||||
colorScheme == .dark ? Color.black.opacity(0.6) : Color.white
|
// Palette-based and slightly translucent so the card doesn't sit as
|
||||||
|
// an opaque white/black box over the glass gradient.
|
||||||
|
palette.background.opacity(colorScheme == .dark ? 0.6 : 0.7)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var borderColor: Color {
|
private var borderColor: Color {
|
||||||
@@ -50,6 +52,12 @@ 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,
|
||||||
@@ -63,7 +71,7 @@ struct VoiceNoteView: View {
|
|||||||
|
|
||||||
Text(playbackLabel)
|
Text(playbackLabel)
|
||||||
.bitchatFont(size: 13)
|
.bitchatFont(size: 13)
|
||||||
.foregroundColor(Color.secondary)
|
.foregroundColor(palette.secondary)
|
||||||
|
|
||||||
if let onCancel = onCancel, isSending {
|
if let onCancel = onCancel, isSending {
|
||||||
Button(action: onCancel) {
|
Button(action: onCancel) {
|
||||||
@@ -74,6 +82,9 @@ 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)
|
||||||
|
|||||||
@@ -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 = Color.gray.opacity(0.35)
|
color = palette.secondary.opacity(0.35)
|
||||||
}
|
}
|
||||||
context.fill(Path(rect), with: .color(color))
|
context.fill(Path(rect), with: .color(color))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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] = []
|
||||||
@@ -15,6 +18,20 @@ 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 {
|
||||||
@@ -49,21 +66,25 @@ 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()
|
||||||
@@ -71,6 +92,8 @@ 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)
|
||||||
@@ -91,6 +114,10 @@ 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 {
|
||||||
@@ -103,6 +130,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +151,11 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -131,8 +164,53 @@ 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())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
// count:2 must attach before count:1 or the single tap
|
||||||
|
// shadows it (same ordering the header logo relies on).
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID) } }
|
||||||
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID) } }
|
||||||
|
.contextMenu {
|
||||||
|
if !isMe {
|
||||||
|
Button(Strings.directMessage) {
|
||||||
|
onTapPeer(peer.peerID)
|
||||||
|
}
|
||||||
|
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||||
|
onToggleFavorite(peer.peerID)
|
||||||
|
}
|
||||||
|
Button(Strings.showFingerprint) {
|
||||||
|
onShowFingerprint(peer.peerID)
|
||||||
|
}
|
||||||
|
if let onToggleBlock {
|
||||||
|
if peer.isBlocked {
|
||||||
|
Button(Strings.unblock) {
|
||||||
|
onToggleBlock(peer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(Strings.block, role: .destructive) {
|
||||||
|
onToggleBlock(peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(accessibilityDescription(for: peer))
|
||||||
|
.accessibilityAddTraits(isMe ? [] : .isButton)
|
||||||
|
.accessibilityHint(isMe ? "" : Strings.openDMHint)
|
||||||
|
.accessibilityActions {
|
||||||
|
if !isMe {
|
||||||
|
Button(peer.isFavorite ? Strings.removeFavorite : Strings.addFavorite) {
|
||||||
|
onToggleFavorite(peer.peerID)
|
||||||
|
}
|
||||||
|
Button(Strings.showFingerprint) {
|
||||||
|
onShowFingerprint(peer.peerID)
|
||||||
|
}
|
||||||
|
if let onToggleBlock {
|
||||||
|
Button(peer.isBlocked ? Strings.unblock : Strings.block) {
|
||||||
|
onToggleBlock(peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
@@ -147,4 +225,25 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One spoken sentence per row: name, how they're reachable, and any
|
||||||
|
/// state badges — the visual row is icon soup for VoiceOver otherwise.
|
||||||
|
private func accessibilityDescription(for peer: MeshPeerRow) -> String {
|
||||||
|
var parts: [String] = [peer.displayName]
|
||||||
|
if !peer.isMe {
|
||||||
|
if peer.isConnected {
|
||||||
|
parts.append(Strings.connected)
|
||||||
|
} else if peer.isReachable {
|
||||||
|
parts.append(Strings.reachable)
|
||||||
|
} else if peer.isMutualFavorite {
|
||||||
|
parts.append(Strings.nostr)
|
||||||
|
} else {
|
||||||
|
parts.append(Strings.offline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if peer.isFavorite { parts.append(Strings.favorite) }
|
||||||
|
if peer.hasUnread { parts.append(Strings.unread) }
|
||||||
|
if peer.isBlocked { parts.append(Strings.blocked) }
|
||||||
|
return parts.joined(separator: ", ")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,8 +36,17 @@ struct MessageListView: View {
|
|||||||
var isTextFieldFocused: FocusState<Bool>.Binding
|
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 = {
|
||||||
@@ -65,6 +74,9 @@ 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
|
||||||
@@ -72,6 +84,7 @@ 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 {
|
||||||
@@ -89,13 +102,32 @@ struct MessageListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture {
|
|
||||||
if message.sender != "system" {
|
|
||||||
messageText = "@\(message.sender) "
|
|
||||||
isTextFieldFocused.wrappedValue = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
let showsUserActions = message.sender != "system" && !conversationUIModel.isSentByCurrentUser(message)
|
||||||
|
if showsUserActions {
|
||||||
|
// Mention and DM are redundant inside a 1:1 conversation:
|
||||||
|
// mentioning the only other participant is noise, and "DM"
|
||||||
|
// would just reopen the conversation that is already open.
|
||||||
|
if privatePeer == nil {
|
||||||
|
Button("content.actions.mention") {
|
||||||
|
insertMention(message.sender)
|
||||||
|
}
|
||||||
|
if let peerID = message.senderPeerID {
|
||||||
|
Button("content.actions.direct_message") {
|
||||||
|
privateConversationModel.openConversation(for: peerID)
|
||||||
|
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||||
|
showSidebar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("content.actions.hug") {
|
||||||
|
conversationUIModel.sendHug(to: message.sender)
|
||||||
|
}
|
||||||
|
Button("content.actions.slap") {
|
||||||
|
conversationUIModel.sendSlap(to: message.sender)
|
||||||
|
}
|
||||||
|
}
|
||||||
Button("content.message.copy") {
|
Button("content.message.copy") {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = message.content
|
UIPasteboard.general.string = message.content
|
||||||
@@ -105,6 +137,16 @@ 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)
|
||||||
@@ -113,9 +155,24 @@ 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) {
|
||||||
conversationUIModel.clearCurrentConversation()
|
showClearConfirmation = true
|
||||||
|
}
|
||||||
|
.confirmationDialog(
|
||||||
|
"content.clear.confirm_title",
|
||||||
|
isPresented: $showClearConfirmation,
|
||||||
|
titleVisibility: .visible
|
||||||
|
) {
|
||||||
|
Button("content.clear.confirm_action", role: .destructive) {
|
||||||
|
conversationUIModel.clearCurrentConversation()
|
||||||
|
}
|
||||||
|
Button("common.cancel", role: .cancel) {}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
scrollToBottom(on: proxy)
|
scrollToBottom(on: proxy)
|
||||||
@@ -139,9 +196,7 @@ struct MessageListView: View {
|
|||||||
) {
|
) {
|
||||||
Button("content.actions.mention") {
|
Button("content.actions.mention") {
|
||||||
if let sender = selectedMessageSender {
|
if let sender = selectedMessageSender {
|
||||||
// Pre-fill the input with an @mention and focus the field
|
insertMention(sender)
|
||||||
messageText = "@\(sender) "
|
|
||||||
isTextFieldFocused.wrappedValue = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +263,142 @@ 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 {
|
||||||
@@ -294,6 +485,9 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -316,15 +510,23 @@ private extension MessageListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onMessagesChange(proxy: ScrollViewProxy) {
|
func onMessagesChange(proxy: ScrollViewProxy) {
|
||||||
|
guard privatePeer == nil else { return }
|
||||||
let messages = publicChatModel.messages
|
let messages = publicChatModel.messages
|
||||||
guard privatePeer == nil, let lastMsg = messages.last else { return }
|
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||||
|
guard let lastMsg = messages.last else {
|
||||||
|
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||||
|
unseenCount = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If the newest message is from me, always scroll to bottom
|
// 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) {
|
||||||
@@ -352,18 +554,23 @@ private extension MessageListView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
func onPrivateChatsChange(proxy: ScrollViewProxy) {
|
||||||
guard let peerID = privatePeer,
|
guard let peerID = privatePeer else { return }
|
||||||
let lastMsg = privateInboxModel.messages(for: peerID).last else {
|
let messages = privateInboxModel.messages(for: peerID)
|
||||||
|
let appendedCount = rebaselinedAppendedCount(newCount: unseenEligibleCount(in: messages))
|
||||||
|
guard let lastMsg = messages.last else {
|
||||||
|
// Timeline emptied (e.g. /clear): nothing below to jump to.
|
||||||
|
unseenCount = 0
|
||||||
return
|
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) {
|
||||||
@@ -391,17 +598,27 @@ 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:
|
||||||
break
|
contextKey = "mesh"
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Reset window size
|
contextKey = "geo:\(ch.geohash)"
|
||||||
isAtBottom = true
|
}
|
||||||
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
|
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
||||||
let contextKey = "geo:\(ch.geohash)"
|
proxy.scrollTo(target, anchor: .bottom)
|
||||||
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
|
|
||||||
proxy.scrollTo(target, anchor: .bottom)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +643,6 @@ private extension ChannelID {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//#Preview {
|
// #Preview {
|
||||||
// MessageListView()
|
// MessageListView()
|
||||||
//}
|
// }
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import AppKit
|
|||||||
struct MyQRView: View {
|
struct MyQRView: View {
|
||||||
let qrString: String
|
let qrString: String
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
@ThemedPalette private var palette
|
||||||
|
// Palette-tinted so the box follows the theme (green under matrix)
|
||||||
|
// instead of a fixed gray band over the glass gradient.
|
||||||
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||||
|
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let title: LocalizedStringKey = "verification.my_qr.title"
|
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||||
@@ -50,6 +53,7 @@ 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()
|
||||||
@@ -65,12 +69,12 @@ struct QRCodeImage: View {
|
|||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
} else {
|
} else {
|
||||||
RoundedRectangle(cornerRadius: 8)
|
RoundedRectangle(cornerRadius: 8)
|
||||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
.stroke(palette.secondary.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(.gray)
|
.foregroundColor(palette.secondary)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,6 +112,7 @@ 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 = ""
|
||||||
@@ -153,7 +158,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(Color.gray.opacity(0.4))
|
.border(palette.secondary.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 {
|
||||||
@@ -265,7 +270,7 @@ struct CameraScannerView: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final class PreviewView: UIView {
|
final class PreviewView: UIView {
|
||||||
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
override static 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)
|
||||||
@@ -285,7 +290,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 { Color.gray.opacity(0.1) }
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -295,15 +300,11 @@ struct VerificationSheetView: View {
|
|||||||
.bitchatFont(size: 14, weight: .bold)
|
.bitchatFont(size: 14, weight: .bold)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
SheetCloseButton {
|
||||||
showingScanner = false
|
showingScanner = false
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
|
||||||
.foregroundColor(accentColor)
|
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.foregroundColor(accentColor)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ final class ShareViewController: UIViewController {
|
|||||||
self.loadFirstPlainText(from: providers) { text in
|
self.loadFirstPlainText(from: providers) { text in
|
||||||
if let t = text, !t.isEmpty {
|
if let t = text, !t.isEmpty {
|
||||||
// Treat as URL if parseable http(s), else plain text
|
// Treat as URL if parseable http(s), else plain text
|
||||||
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
|
if let u = URL(string: t), ["http", "https"].contains(u.scheme?.lowercased() ?? "") {
|
||||||
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
|
||||||
} else {
|
} else {
|
||||||
self.saveAndFinish(text: t)
|
self.saveAndFinish(text: t)
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ struct ChatComposerCoordinatorContextTests {
|
|||||||
context.meshNicknamesByPeerID = [
|
context.meshNicknamesByPeerID = [
|
||||||
PeerID(str: "1111111111111111"): "alice",
|
PeerID(str: "1111111111111111"): "alice",
|
||||||
PeerID(str: "2222222222222222"): "bob",
|
PeerID(str: "2222222222222222"): "bob",
|
||||||
PeerID(str: "3333333333333333"): "me",
|
PeerID(str: "3333333333333333"): "me"
|
||||||
]
|
]
|
||||||
|
|
||||||
// Matching query: suggestions and range are published, index resets.
|
// Matching query: suggestions and range are published, index resets.
|
||||||
@@ -113,7 +113,7 @@ struct ChatComposerCoordinatorContextTests {
|
|||||||
"aaaabbbbccccdddd": "carol",
|
"aaaabbbbccccdddd": "carol",
|
||||||
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
|
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
|
||||||
// identity's public key hex ends in "2222".
|
// identity's public key hex ends in "2222".
|
||||||
"ffffeeeeddddcccc2222": "me",
|
"ffffeeeeddddcccc2222": "me"
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
|
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
|
||||||
|
|||||||
@@ -214,10 +214,10 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
// Same message under both keys: the read copy must win over sent.
|
// Same message under both keys: the read copy must win over sent.
|
||||||
context.privateChats[peerID] = [
|
context.privateChats[peerID] = [
|
||||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
|
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
|
||||||
makePrivateMessage(id: "m2", timestamp: t2),
|
makePrivateMessage(id: "m2", timestamp: t2)
|
||||||
]
|
]
|
||||||
context.privateChats[stablePeerID] = [
|
context.privateChats[stablePeerID] = [
|
||||||
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
|
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2))
|
||||||
]
|
]
|
||||||
|
|
||||||
let merged = coordinator.getPrivateChatMessages(for: peerID)
|
let merged = coordinator.getPrivateChatMessages(for: peerID)
|
||||||
@@ -245,7 +245,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
makePrivateMessage(id: "m1", senderPeerID: convKey),
|
makePrivateMessage(id: "m1", senderPeerID: convKey),
|
||||||
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
|
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
|
||||||
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
|
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
|
||||||
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
|
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID)
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.markPrivateMessagesAsRead(from: convKey)
|
coordinator.markPrivateMessagesAsRead(from: convKey)
|
||||||
@@ -339,7 +339,7 @@ struct ChatLifecycleCoordinatorContextTests {
|
|||||||
)
|
)
|
||||||
context.privateChats[peerID] = [
|
context.privateChats[peerID] = [
|
||||||
makePrivateMessage(id: "in-1", senderPeerID: peerID),
|
makePrivateMessage(id: "in-1", senderPeerID: peerID),
|
||||||
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
|
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true)
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.markPrivateMessagesAsRead(from: peerID)
|
coordinator.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
|||||||
@@ -154,18 +154,18 @@ struct ChatPeerListCoordinatorContextTests {
|
|||||||
peerID: currentPeer,
|
peerID: currentPeer,
|
||||||
noisePublicKey: Data(repeating: 0x01, count: 32),
|
noisePublicKey: Data(repeating: 0x01, count: 32),
|
||||||
nickname: "alice"
|
nickname: "alice"
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
context.unreadPrivateMessages = [
|
context.unreadPrivateMessages = [
|
||||||
currentPeer,
|
currentPeer,
|
||||||
staleShortPeer,
|
staleShortPeer,
|
||||||
geoDMWithMessages,
|
geoDMWithMessages,
|
||||||
geoDMWithoutMessages,
|
geoDMWithoutMessages,
|
||||||
noiseKeyWithMessages,
|
noiseKeyWithMessages
|
||||||
]
|
]
|
||||||
context.privateChats = [
|
context.privateChats = [
|
||||||
geoDMWithMessages: [makeMessage(id: "geo-1")],
|
geoDMWithMessages: [makeMessage(id: "geo-1")],
|
||||||
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
|
noiseKeyWithMessages: [makeMessage(id: "noise-1")]
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.didUpdatePeerList([currentPeer])
|
coordinator.didUpdatePeerList([currentPeer])
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
|||||||
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
||||||
context.privateChats[convKey] = [
|
context.privateChats[convKey] = [
|
||||||
makeIncomingMessage(id: "mine-1", sender: "me"),
|
makeIncomingMessage(id: "mine-1", sender: "me"),
|
||||||
makeIncomingMessage(id: "mine-2", sender: "me"),
|
makeIncomingMessage(id: "mine-2", sender: "me")
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.handleDelivered(
|
coordinator.handleDelivered(
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
context.privateChats[peerID] = [
|
context.privateChats[peerID] = [
|
||||||
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
|
makeMessage(id: "theirs-1", isPrivate: true, senderPeerID: peerID),
|
||||||
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
makeMessage(id: "mine-1", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
||||||
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID),
|
makeMessage(id: "theirs-2", isPrivate: true, senderPeerID: peerID)
|
||||||
]
|
]
|
||||||
coordinator.didDisconnectFromPeer(peerID)
|
coordinator.didDisconnectFromPeer(peerID)
|
||||||
await drainMainActorTasks()
|
await drainMainActorTasks()
|
||||||
@@ -282,7 +282,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
context.unreadPrivateMessages = [peerID]
|
context.unreadPrivateMessages = [peerID]
|
||||||
context.privateChats[peerID] = [
|
context.privateChats[peerID] = [
|
||||||
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
|
makeMessage(id: "m1", isPrivate: true, senderPeerID: peerID),
|
||||||
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID),
|
makeMessage(id: "mine", sender: "me", isPrivate: true, senderPeerID: context.myPeerID)
|
||||||
]
|
]
|
||||||
|
|
||||||
coordinator.didDisconnectFromPeer(peerID)
|
coordinator.didDisconnectFromPeer(peerID)
|
||||||
|
|||||||
@@ -428,12 +428,13 @@ 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 < partiallyDelivered < delivered < read
|
// failed < sending < sent < carried < 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())
|
||||||
@@ -446,9 +447,10 @@ 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 .partiallyDelivered: #expect(index == 3)
|
case .carried: #expect(index == 3)
|
||||||
case .delivered: #expect(index == 4)
|
case .partiallyDelivered: #expect(index == 4)
|
||||||
case .read: #expect(index == 5)
|
case .delivered: #expect(index == 5)
|
||||||
|
case .read: #expect(index == 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,8 +297,23 @@ struct ChatViewModelNostrExtensionTests {
|
|||||||
|
|
||||||
let didAppend = await TestHelpers.waitUntil({
|
let didAppend = await TestHelpers.waitUntil({
|
||||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||||
return viewModel.messages.contains { $0.content == "Hello Geo" }
|
if viewModel.messages.contains(where: { $0.content == "Hello Geo" }) { return true }
|
||||||
})
|
// LocationChannelManager is a process-wide singleton: a suite
|
||||||
|
// running in parallel (e.g. CommandProcessorTests) can flip the
|
||||||
|
// selected channel mid-test, which reroutes or drops the event
|
||||||
|
// permanently — no amount of waiting recovers it. Re-assert the
|
||||||
|
// channel and redeliver on each poll: every channel switch clears
|
||||||
|
// the processed-event set and the store dedups by message ID, so
|
||||||
|
// redelivery is idempotent and interference heals on the next
|
||||||
|
// poll while a genuine failure still times out.
|
||||||
|
if LocationChannelManager.shared.selectedChannel != channel {
|
||||||
|
LocationChannelManager.shared.select(channel)
|
||||||
|
}
|
||||||
|
if viewModel.activeChannel == channel {
|
||||||
|
viewModel.handleNostrEvent(signed)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, timeout: TestConstants.longTimeout)
|
||||||
#expect(didAppend)
|
#expect(didAppend)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1000,7 +1015,11 @@ struct ChatViewModelMediaTransferTests {
|
|||||||
viewModel.selectedPrivateChatPeer = peerID
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
viewModel.sendVoiceNote(at: url)
|
viewModel.sendVoiceNote(at: url)
|
||||||
|
|
||||||
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 5.0)
|
// Media sends hop through Task.detached; the global executor is
|
||||||
|
// shared with every parallel test worker, so a loaded runner can
|
||||||
|
// exceed the 5s default. waitUntil returns as soon as the condition
|
||||||
|
// holds, so passing runs never pay the longer timeout.
|
||||||
|
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: TestConstants.longTimeout)
|
||||||
#expect(didSend)
|
#expect(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)
|
||||||
@@ -1020,7 +1039,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: 5.0)
|
}, timeout: TestConstants.longTimeout)
|
||||||
#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)
|
||||||
@@ -1036,7 +1055,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: 5.0)
|
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(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
|
||||||
@@ -1057,7 +1076,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: 5.0)
|
}, timeout: TestConstants.longTimeout)
|
||||||
#expect(didNotify)
|
#expect(didNotify)
|
||||||
#expect(transport.sentPrivateFiles.isEmpty)
|
#expect(transport.sentPrivateFiles.isEmpty)
|
||||||
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
|
||||||
|
|||||||
@@ -263,6 +263,59 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
//
|
||||||
|
// CourierStoreTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct CourierStoreTests {
|
||||||
|
|
||||||
|
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
|
||||||
|
private func makeStore(now: Date = baseDate) -> CourierStore {
|
||||||
|
CourierStore(persistsToDisk: false, now: { now })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store whose clock can be advanced by tests.
|
||||||
|
private final class Clock {
|
||||||
|
var now: Date
|
||||||
|
init(_ now: Date) { self.now = now }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEnvelope(
|
||||||
|
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
||||||
|
sealedAt: Date = baseDate,
|
||||||
|
lifetime: TimeInterval = 60 * 60,
|
||||||
|
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
) -> CourierEnvelope {
|
||||||
|
CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: recipientKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
||||||
|
),
|
||||||
|
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
||||||
|
ciphertext: ciphertext
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private let depositorA = Data(repeating: 0xA1, count: 32)
|
||||||
|
private let depositorB = Data(repeating: 0xA2, count: 32)
|
||||||
|
|
||||||
|
// MARK: - Deposit and handover
|
||||||
|
|
||||||
|
@Test func depositThenTakeForRecipient() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
|
||||||
|
#expect(store.deposit(envelope, from: depositorA))
|
||||||
|
let taken = store.takeEnvelopes(for: recipientKey)
|
||||||
|
#expect(taken == [envelope])
|
||||||
|
// Handover removes the envelope.
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func takeIgnoresOtherRecipients() {
|
||||||
|
let store = makeStore()
|
||||||
|
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
||||||
|
store.deposit(envelope, from: depositorA)
|
||||||
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
||||||
|
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateDepositIsIdempotent() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
#expect(store.deposit(envelope, from: depositorA))
|
||||||
|
#expect(store.deposit(envelope, from: depositorA))
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validity
|
||||||
|
|
||||||
|
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
||||||
|
let store = makeStore()
|
||||||
|
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
||||||
|
#expect(!store.deposit(expired, from: depositorA))
|
||||||
|
|
||||||
|
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
||||||
|
#expect(!store.deposit(oversized, from: depositorA))
|
||||||
|
|
||||||
|
let badTag = CourierEnvelope(
|
||||||
|
recipientTag: Data(repeating: 0, count: 4),
|
||||||
|
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
||||||
|
ciphertext: Data(repeating: 1, count: 16)
|
||||||
|
)
|
||||||
|
#expect(!store.deposit(badTag, from: depositorA))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
||||||
|
let store = makeStore()
|
||||||
|
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
||||||
|
#expect(!store.deposit(pinned, from: depositorA))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quotas
|
||||||
|
|
||||||
|
@Test func perDepositorQuota() {
|
||||||
|
let store = makeStore()
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerDepositor {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||||
|
}
|
||||||
|
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
||||||
|
// A different depositor still has room.
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func totalQuotaEvictsOldestFirst() {
|
||||||
|
let store = makeStore()
|
||||||
|
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
||||||
|
let first = makeEnvelope(recipientKey: firstRecipient)
|
||||||
|
store.deposit(first, from: depositorA)
|
||||||
|
|
||||||
|
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
||||||
|
var deposited = 1
|
||||||
|
var depositorByte: UInt8 = 1
|
||||||
|
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||||
|
let depositor = Data(repeating: depositorByte, count: 32)
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositor))
|
||||||
|
deposited += 1
|
||||||
|
}
|
||||||
|
depositorByte += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first envelope was evicted to make room.
|
||||||
|
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expiry over time
|
||||||
|
|
||||||
|
@Test func expiredEnvelopesAreNotHandedOver() {
|
||||||
|
let clock = Clock(Self.baseDate)
|
||||||
|
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
||||||
|
|
||||||
|
clock.now = Self.baseDate.addingTimeInterval(7200)
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Panic wipe
|
||||||
|
|
||||||
|
@Test func wipeDropsEverything() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
||||||
|
store.wipe()
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
@Test func persistsAndReloadsAcrossInstances() throws {
|
||||||
|
// Isolated on-disk location so the test never touches the real store.
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
.appendingPathComponent("envelopes.json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
||||||
|
|
||||||
|
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
|
|
||||||
|
let recipientKey = Data(repeating: 0xE0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
#expect(first.deposit(envelope, from: depositorA))
|
||||||
|
|
||||||
|
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
|
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
//
|
||||||
|
// CourierEndToEndTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// Three-node courier flow exercised through real BLEService instances with
|
||||||
|
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
|
||||||
|
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
|
||||||
|
/// opens it and sees Alice's message in the right DM thread.
|
||||||
|
struct CourierEndToEndTests {
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private final class PacketTap {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var packets: [BitchatPacket] = []
|
||||||
|
|
||||||
|
func record(_ packet: BitchatPacket) {
|
||||||
|
lock.lock(); packets.append(packet); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func first(ofType type: MessageType) -> BitchatPacket? {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets.first { $0.type == type.rawValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
func count(ofType type: MessageType) -> Int {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets.filter { $0.type == type.rawValue }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func all(ofType type: MessageType) -> [BitchatPacket] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return packets.filter { $0.type == type.rawValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
|
||||||
|
|
||||||
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
|
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
|
||||||
|
lock.lock(); defer { lock.unlock() }
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused BitchatDelegate requirements.
|
||||||
|
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||||
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let identityManager = identityManager ?? MockIdentityManager(keychain)
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
|
let service = BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
initializeBluetoothManagers: false
|
||||||
|
)
|
||||||
|
service.courierStore = CourierStore(persistsToDisk: false)
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handling any packet from a peer preseeds it as a connected,
|
||||||
|
/// verified entry in the receiving service's registry.
|
||||||
|
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data("ping".utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tests
|
||||||
|
|
||||||
|
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
// Alice and Carol are mutual favorites; trust policy is exercised
|
||||||
|
// separately in depositFromUntrustedPeerIsRejected.
|
||||||
|
carol.courierDepositPolicy = { _ in true }
|
||||||
|
|
||||||
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
|
bob.delegate = bobDelegate
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
// Alice can see Carol; Bob is nowhere on the mesh.
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
// 1. Alice seals to Bob's static key and deposits with Carol.
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"the camp moved north",
|
||||||
|
messageID: "courier-msg-1",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
// 3. Later, Bob announces near Carol → handover fires.
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announced)
|
||||||
|
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||||
|
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
#expect(carol.courierStore.isEmpty)
|
||||||
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
|
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
||||||
|
|
||||||
|
// 4. Ferry the handover to Bob; he opens the envelope.
|
||||||
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
|
let received = await TestHelpers.waitUntil(
|
||||||
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(received)
|
||||||
|
|
||||||
|
let delivered = try #require(bobDelegate.snapshot().first)
|
||||||
|
#expect(delivered.type == .privateMessage)
|
||||||
|
// Sender resolves to Alice's stable mesh identity, not the courier's.
|
||||||
|
#expect(delivered.peerID == alice.myPeerID)
|
||||||
|
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||||
|
#expect(message.messageID == "courier-msg-1")
|
||||||
|
#expect(message.content == "the camp moved north")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bobIdentity = MockIdentityManager(MockKeychain())
|
||||||
|
let bob = makeService(identityManager: bobIdentity)
|
||||||
|
carol.courierDepositPolicy = { _ in true }
|
||||||
|
|
||||||
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
|
bob.delegate = bobDelegate
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
// Bob blocked Alice by her stable Noise identity while she was away.
|
||||||
|
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
|
||||||
|
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"you should not see this",
|
||||||
|
messageID: "courier-msg-blocked-sender",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announced)
|
||||||
|
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||||
|
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
// Bob opens the envelope — but the sealed sender is blocked, and it
|
||||||
|
// must never reach the UI. The live block check can't cover this: the
|
||||||
|
// sender is absent from Bob's registry, so no fingerprint resolves at
|
||||||
|
// delivery time.
|
||||||
|
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||||
|
let delivered = await TestHelpers.waitUntil(
|
||||||
|
{ !bobDelegate.snapshot().isEmpty },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!delivered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
carol.courierDepositPolicy = { _ in true }
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"hold until verified",
|
||||||
|
messageID: "courier-msg-unverified-announce",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
|
||||||
|
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!leakedOnUnverifiedAnnounce)
|
||||||
|
#expect(!carol.courierStore.isEmpty)
|
||||||
|
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announced)
|
||||||
|
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
|
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
#expect(carol.courierStore.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let bob = makeService()
|
||||||
|
carol.courierDepositPolicy = { _ in true }
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
let carolOut = PacketTap()
|
||||||
|
carol._test_onOutboundPacket = carolOut.record
|
||||||
|
let bobOut = PacketTap()
|
||||||
|
bob._test_onOutboundPacket = bobOut.record
|
||||||
|
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
#expect(alice.sendCourierMessage(
|
||||||
|
"hold for a direct encounter",
|
||||||
|
messageID: "courier-msg-relayed-announce",
|
||||||
|
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
let deposited = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(deposited)
|
||||||
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
||||||
|
let carried = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(carried)
|
||||||
|
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.first(ofType: .announce) != nil },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announced)
|
||||||
|
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
|
|
||||||
|
// A relayed copy has a decremented TTL but a still-valid signature
|
||||||
|
// (TTL is excluded from announce signatures). Envelopes are removed
|
||||||
|
// from the store optimistically, so handover must wait for a direct
|
||||||
|
// encounter instead of chasing a multi-hop path.
|
||||||
|
var relayedAnnounce = directAnnounce
|
||||||
|
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
||||||
|
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!leakedOnRelayedAnnounce)
|
||||||
|
#expect(!carol.courierStore.isEmpty)
|
||||||
|
|
||||||
|
// The relayed copy consumed the original announce's dedup key
|
||||||
|
// (sender/timestamp/payload — TTL excluded), so the direct handover
|
||||||
|
// needs a fresh announce. Wait out the 1s announce throttle first.
|
||||||
|
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(reannounced)
|
||||||
|
let freshAnnounce = try #require(
|
||||||
|
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
||||||
|
)
|
||||||
|
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(handedOver)
|
||||||
|
#expect(carol.courierStore.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
preseedConnectedPeer(carol, in: alice)
|
||||||
|
|
||||||
|
let aliceOut = PacketTap()
|
||||||
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
|
|
||||||
|
#expect(!alice.sendCourierMessage(
|
||||||
|
"this cannot be sealed",
|
||||||
|
messageID: "courier-msg-invalid-key",
|
||||||
|
recipientNoiseKey: Data(repeating: 0x01, count: 8),
|
||||||
|
via: [carol.myPeerID]
|
||||||
|
))
|
||||||
|
|
||||||
|
let queuedPacket = await TestHelpers.waitUntil(
|
||||||
|
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!queuedPacket)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
||||||
|
let carol = makeService()
|
||||||
|
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
|
||||||
|
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||||
|
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
|
||||||
|
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||||
|
let now = Date()
|
||||||
|
let envelope = CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: bobKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||||
|
ciphertext: sealed
|
||||||
|
)
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: carol.myPeerID.id),
|
||||||
|
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||||
|
payload: try #require(envelope.encode()),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
|
||||||
|
let stored = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
|
||||||
|
let alice = makeService()
|
||||||
|
let carol = makeService()
|
||||||
|
let mallory = makeService()
|
||||||
|
preseedConnectedPeer(alice, in: carol)
|
||||||
|
preseedConnectedPeer(mallory, in: carol)
|
||||||
|
|
||||||
|
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
|
||||||
|
carol.courierDepositPolicy = { depositorKey in
|
||||||
|
depositorKey == trustedAliceKey
|
||||||
|
}
|
||||||
|
|
||||||
|
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||||
|
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
|
||||||
|
let sealed = try aliceNoise.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||||
|
let now = Date()
|
||||||
|
let envelope = CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: bobKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||||
|
ciphertext: sealed
|
||||||
|
)
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: carol.myPeerID.id),
|
||||||
|
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||||
|
payload: try #require(envelope.encode()),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||||
|
let stored = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
|
||||||
|
let announcement = AnnouncementPacket(
|
||||||
|
nickname: "Unsigned",
|
||||||
|
noisePublicKey: service.noiseStaticPublicKeyData(),
|
||||||
|
signingPublicKey: service.noiseSigningPublicKeyData(),
|
||||||
|
directNeighbors: nil
|
||||||
|
)
|
||||||
|
let payload = try #require(announcement.encode())
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: MessageType.announce.rawValue,
|
||||||
|
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Router courier selection
|
||||||
|
|
||||||
|
/// Minimal transport stub for exercising MessageRouter's courier deposit
|
||||||
|
/// logic without BLE plumbing.
|
||||||
|
private final class CourierCaptureTransport: Transport {
|
||||||
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var eventDelegate: TransportEventDelegate?
|
||||||
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
|
var snapshots: [TransportPeerSnapshot] = []
|
||||||
|
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
|
||||||
|
private(set) var directSends: [String] = []
|
||||||
|
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
Just(snapshots).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
|
||||||
|
|
||||||
|
var myPeerID = PeerID(str: "00000000000000aa")
|
||||||
|
var myNickname = "stub"
|
||||||
|
func setNickname(_ nickname: String) {}
|
||||||
|
|
||||||
|
func startServices() {}
|
||||||
|
func stopServices() {}
|
||||||
|
func emergencyDisconnectAll() {}
|
||||||
|
|
||||||
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
||||||
|
}
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool { isPeerConnected(peerID) }
|
||||||
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
|
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||||
|
|
||||||
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
|
func triggerHandshake(with peerID: PeerID) {}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String, mentions: [String]) {}
|
||||||
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
|
directSends.append(messageID)
|
||||||
|
}
|
||||||
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||||
|
func sendBroadcastAnnounce() {}
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
|
||||||
|
|
||||||
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||||
|
courierSends.append((messageID, recipientNoiseKey, couriers))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MessageRouterCourierTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
|
||||||
|
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let bobID = PeerID(publicKey: bobKey)
|
||||||
|
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||||
|
let carolID = PeerID(publicKey: carolKey)
|
||||||
|
let daveKey = Data(repeating: 0xD0, count: 32)
|
||||||
|
let daveID = PeerID(publicKey: daveKey)
|
||||||
|
|
||||||
|
let transport = CourierCaptureTransport()
|
||||||
|
transport.snapshots = [
|
||||||
|
// Carol: connected mutual favorite → eligible courier.
|
||||||
|
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
|
||||||
|
// Dave: connected but not trusted → never a courier.
|
||||||
|
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
|
||||||
|
]
|
||||||
|
|
||||||
|
let directory = CourierDirectory(
|
||||||
|
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||||
|
isTrustedCourier: { $0 == carolKey }
|
||||||
|
)
|
||||||
|
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||||
|
var carried: [String] = []
|
||||||
|
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||||
|
|
||||||
|
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
|
||||||
|
|
||||||
|
#expect(transport.directSends.isEmpty)
|
||||||
|
#expect(transport.courierSends.count == 1)
|
||||||
|
#expect(transport.courierSends.first?.messageID == "m1")
|
||||||
|
#expect(transport.courierSends.first?.recipientKey == bobKey)
|
||||||
|
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||||
|
#expect(carried == ["m1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func noCourierDepositWithoutKnownRecipientKey() {
|
||||||
|
let transport = CourierCaptureTransport()
|
||||||
|
transport.snapshots = [
|
||||||
|
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
|
||||||
|
]
|
||||||
|
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
|
||||||
|
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||||
|
var carried: [String] = []
|
||||||
|
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||||
|
|
||||||
|
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
|
||||||
|
|
||||||
|
#expect(transport.courierSends.isEmpty)
|
||||||
|
#expect(carried.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func reachablePeerSkipsCourier() {
|
||||||
|
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let bobID = PeerID(publicKey: bobKey)
|
||||||
|
let transport = CourierCaptureTransport()
|
||||||
|
transport.snapshots = [
|
||||||
|
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
|
||||||
|
]
|
||||||
|
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
|
||||||
|
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||||
|
|
||||||
|
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
|
||||||
|
|
||||||
|
#expect(transport.directSends == ["m3"])
|
||||||
|
#expect(transport.courierSends.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ struct IntegrationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Encrypted path: use NoiseSessionManager explicitly
|
// Encrypted path: use NoiseSessionManager explicitly
|
||||||
let plaintext = "Encrypted message".data(using: .utf8)!
|
let plaintext = Data("Encrypted message".utf8)
|
||||||
let ciphertext = try helper.noiseManagers["Alice"]!.encrypt(plaintext, for: helper.nodes["Bob"]!.peerID)
|
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 = { message in
|
helper.nodes["David"]!.messageDeliveryHandler = { _ in
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ struct IntegrationTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let plaintext = "After restart success".data(using: .utf8)!
|
let plaintext = Data("After restart success".utf8)
|
||||||
let ciphertext = try helper.noiseManagers["Bob"]!.encrypt(plaintext, for: helper.nodes["Alice"]!.peerID)
|
let 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,4 +121,3 @@ final class TestNetworkHelper {
|
|||||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = "Hello, Bob!".data(using: .utf8)!
|
let plaintext = Data("Hello, Bob!".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 = "Hello from Alice".data(using: .utf8)!
|
let aliceMessage = Data("Hello from Alice".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 = "Hello from Bob".data(using: .utf8)!
|
let bobMessage = Data("Hello from Bob".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 = "test".data(using: .utf8)!
|
let plaintext = Data("test".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 = "Test message".data(using: .utf8)!
|
let plaintext = Data("Test message".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 = "Secret message".data(using: .utf8)!
|
let plaintext = Data("Secret message".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 = "Test message".data(using: .utf8)!
|
let plaintext = Data("Test message".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 = "Secret".data(using: .utf8)!
|
let plaintext = Data("Secret".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("Hello".data(using: .utf8)!, for: alicePeerID)
|
let message1 = try aliceManager.encrypt(Data("Hello".utf8), for: alicePeerID)
|
||||||
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
_ = try bobManager.decrypt(message1, from: bobPeerID)
|
||||||
|
|
||||||
let message2 = try bobManager.encrypt("World".data(using: .utf8)!, for: bobPeerID)
|
let message2 = try bobManager.encrypt(Data("World".utf8), for: bobPeerID)
|
||||||
_ = try aliceManager.decrypt(message2, from: alicePeerID)
|
_ = 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 = "After restart".data(using: .utf8)!
|
let testMessage = Data("After restart".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("Message \(i)".data(using: .utf8)!)
|
let msg = try aliceSession.encrypt(Data("Message \(i)".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("Lost message \(i)".data(using: .utf8)!)
|
_ = try aliceSession.encrypt(Data("Lost message \(i)".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("This now succeeds".data(using: .utf8)!)
|
let desyncMessage = try aliceSession.encrypt(Data("This now succeeds".utf8))
|
||||||
#expect(throws: Never.self) {
|
#expect(throws: Never.self) {
|
||||||
try bobSession.decrypt(desyncMessage)
|
try bobSession.decrypt(desyncMessage)
|
||||||
}
|
}
|
||||||
@@ -434,12 +434,11 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
let messageCount = 100
|
let messageCount = 100
|
||||||
|
|
||||||
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount)
|
try await confirmation("All messages encrypted and decrypted", expectedCount: messageCount) { completion in
|
||||||
{ 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 = "Concurrent message \(i)".data(using: .utf8)!
|
let plaintext = Data("Concurrent message \(i)".utf8)
|
||||||
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
let encrypted = try aliceManager.encrypt(plaintext, for: alicePeerID)
|
||||||
encryptedMessages[i] = encrypted
|
encryptedMessages[i] = encrypted
|
||||||
}
|
}
|
||||||
@@ -452,7 +451,7 @@ struct NoiseProtocolTests {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
let decrypted = try bobManager.decrypt(encrypted, from: bobPeerID)
|
||||||
let expected = "Concurrent message \(i)".data(using: .utf8)!
|
let expected = Data("Concurrent message \(i)".utf8)
|
||||||
#expect(decrypted == expected)
|
#expect(decrypted == expected)
|
||||||
completion()
|
completion()
|
||||||
} catch {
|
} catch {
|
||||||
@@ -485,7 +484,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("Test".data(using: .utf8)!, for: alicePeerID)
|
var encrypted = try aliceManager.encrypt(Data("Test".utf8), for: alicePeerID)
|
||||||
encrypted[10] ^= 0xFF // Corrupt the data
|
encrypted[10] ^= 0xFF // Corrupt the data
|
||||||
|
|
||||||
// Decryption should fail
|
// Decryption should fail
|
||||||
@@ -516,7 +515,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 = "Session works".data(using: .utf8)!
|
let testMessage = Data("Session works".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)
|
||||||
@@ -539,7 +538,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 = "New session works".data(using: .utf8)!
|
let testMessage2 = Data("New session works".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)
|
||||||
@@ -555,18 +554,18 @@ struct NoiseProtocolTests {
|
|||||||
|
|
||||||
// Exchange messages normally
|
// Exchange messages normally
|
||||||
for i in 0..<5 {
|
for i in 0..<5 {
|
||||||
let msg = try aliceManager.encrypt("Message \(i)".data(using: .utf8)!, for: alicePeerID)
|
let msg = try aliceManager.encrypt(Data("Message \(i)".utf8), for: alicePeerID)
|
||||||
_ = try bobManager.decrypt(msg, from: bobPeerID)
|
_ = 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("Lost message \(i)".data(using: .utf8)!, for: alicePeerID)
|
_ = try aliceManager.encrypt(Data("Lost message \(i)".utf8), for: alicePeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// With nonce carried in packet, decryption should not throw here
|
// With nonce carried in packet, decryption should not throw here
|
||||||
let desyncMessage = try aliceManager.encrypt(
|
let desyncMessage = try aliceManager.encrypt(
|
||||||
"This now succeeds".data(using: .utf8)!, for: alicePeerID)
|
Data("This now succeeds".utf8), for: alicePeerID)
|
||||||
#expect(throws: Never.self) {
|
#expect(throws: Never.self) {
|
||||||
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
try bobManager.decrypt(desyncMessage, from: bobPeerID)
|
||||||
}
|
}
|
||||||
@@ -587,7 +586,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 = "Resynced".data(using: .utf8)!
|
let testResynced = Data("Resynced".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)
|
||||||
@@ -900,20 +899,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 = "Hello from Alice after secureClear!".data(using: .utf8)!
|
let plaintext1 = Data("Hello from Alice after secureClear!".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 = "Hello from Bob after secureClear!".data(using: .utf8)!
|
let plaintext2 = Data("Hello from Bob after secureClear!".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 = "Message \(i) from Alice".data(using: .utf8)!
|
let msg = Data("Message \(i) from Alice".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)
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// NoiseCourierTests.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
/// One-way Noise X envelopes: encryption to a known static key without an
|
||||||
|
/// interactive handshake, used by the courier store-and-forward path.
|
||||||
|
struct NoiseCourierTests {
|
||||||
|
|
||||||
|
@Test func sealAndOpenRoundTrip() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|
||||||
|
let payload = Data("meet at the north gate".utf8)
|
||||||
|
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
let opened = try bob.openCourierPayload(sealed)
|
||||||
|
#expect(opened.payload == payload)
|
||||||
|
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||||
|
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wrongRecipientCannotOpen() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|
||||||
|
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try carol.openCourierPayload(sealed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tamperedEnvelopeFailsToOpen() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|
||||||
|
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
sealed[sealed.count - 1] ^= 0x01
|
||||||
|
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openCourierPayload(sealed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func senderIdentityCannotBeForged() throws {
|
||||||
|
// The encrypted static key inside the envelope is bound by the ss DH;
|
||||||
|
// splicing one envelope's ephemeral prefix onto another must fail.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|
||||||
|
let bobKey = bob.getStaticPublicKeyData()
|
||||||
|
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||||
|
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||||
|
|
||||||
|
// e (32 bytes) from Mallory's envelope + rest from Alice's.
|
||||||
|
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try bob.openCourierPayload(Data(spliced))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sealRejectsInvalidRecipientKey() {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
|
||||||
|
}
|
||||||
|
#expect(throws: (any Error).self) {
|
||||||
|
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func emptyAndLargePayloadsRoundTrip() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bobKey = bob.getStaticPublicKeyData()
|
||||||
|
|
||||||
|
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
|
||||||
|
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
|
||||||
|
|
||||||
|
let large = Data((0..<8192).map { UInt8($0 % 251) })
|
||||||
|
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
|
||||||
|
#expect(try bob.openCourierPayload(sealed).payload == large)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envelopesAreNotLinkableAcrossSends() throws {
|
||||||
|
// Fresh ephemeral per seal: same payload to the same recipient must
|
||||||
|
// produce entirely different ciphertexts.
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let payload = Data("same message".utf8)
|
||||||
|
|
||||||
|
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||||
|
#expect(a != b)
|
||||||
|
#expect(a.prefix(32) != b.prefix(32))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
//
|
|
||||||
// TorEgressVerifierTests.swift
|
|
||||||
// bitchatTests
|
|
||||||
//
|
|
||||||
// Unit tests for the runtime Tor-egress self-check policy/caching. The network
|
|
||||||
// probe is injected, so these tests are deterministic and offline.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Testing
|
|
||||||
import Foundation
|
|
||||||
@testable import bitchat
|
|
||||||
import Tor
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct TorEgressVerifierTests {
|
|
||||||
|
|
||||||
/// Deterministic, controllable clock + probe.
|
|
||||||
private final class Harness: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var _now = Date(timeIntervalSince1970: 1_000_000)
|
|
||||||
private var _result: TorEgressVerifier.ProbeResult = .verifiedTor
|
|
||||||
private var _hanging = false
|
|
||||||
private var _gated = false
|
|
||||||
private var _released = false
|
|
||||||
private var _probeCount = 0
|
|
||||||
private var _cancelledCount = 0
|
|
||||||
|
|
||||||
var now: Date {
|
|
||||||
lock.lock(); defer { lock.unlock() }; return _now
|
|
||||||
}
|
|
||||||
var probeCount: Int {
|
|
||||||
lock.lock(); defer { lock.unlock() }; return _probeCount
|
|
||||||
}
|
|
||||||
/// Number of hung probes that observed cooperative cancellation.
|
|
||||||
var cancelledCount: Int {
|
|
||||||
lock.lock(); defer { lock.unlock() }; return _cancelledCount
|
|
||||||
}
|
|
||||||
func advance(_ seconds: TimeInterval) {
|
|
||||||
lock.lock(); _now = _now.addingTimeInterval(seconds); lock.unlock()
|
|
||||||
}
|
|
||||||
func setResult(_ r: TorEgressVerifier.ProbeResult) {
|
|
||||||
lock.lock(); _result = r; lock.unlock()
|
|
||||||
}
|
|
||||||
/// When `true`, probes park forever and only exit via cooperative
|
|
||||||
/// cancellation — models a canary request wedged by
|
|
||||||
/// `waitsForConnectivity` deferring the request timer.
|
|
||||||
func setHanging(_ hanging: Bool) {
|
|
||||||
lock.lock(); _hanging = hanging; lock.unlock()
|
|
||||||
}
|
|
||||||
/// When `true`, probes wait for `release()` before returning — models
|
|
||||||
/// a slow-but-completing canary for join-semantics tests.
|
|
||||||
func setGated(_ gated: Bool) {
|
|
||||||
lock.lock(); _gated = gated; lock.unlock()
|
|
||||||
}
|
|
||||||
func release() {
|
|
||||||
lock.lock(); _released = true; lock.unlock()
|
|
||||||
}
|
|
||||||
/// Suspends until at least `n` probes have started.
|
|
||||||
func waitUntilProbeCount(atLeast n: Int) async {
|
|
||||||
while probeCount < n {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func makeProbe() -> @Sendable () async -> TorEgressVerifier.ProbeResult {
|
|
||||||
return { [self] in
|
|
||||||
lock.lock()
|
|
||||||
_probeCount += 1
|
|
||||||
let r = _result
|
|
||||||
let hang = _hanging
|
|
||||||
let gated = _gated
|
|
||||||
lock.unlock()
|
|
||||||
if hang {
|
|
||||||
// Park until cancelled (verifier watchdog or invalidate());
|
|
||||||
// cancellation-responsive so no task outlives the test.
|
|
||||||
while !Task.isCancelled {
|
|
||||||
try? await Task.sleep(nanoseconds: 2_000_000)
|
|
||||||
}
|
|
||||||
lock.lock(); _cancelledCount += 1; lock.unlock()
|
|
||||||
return .unreachable("hung probe cancelled")
|
|
||||||
}
|
|
||||||
if gated {
|
|
||||||
while !Task.isCancelled {
|
|
||||||
lock.lock(); let released = _released; lock.unlock()
|
|
||||||
if released { break }
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func nowProvider() -> @Sendable () -> Date {
|
|
||||||
return { [self] in self.now }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeVerifier(
|
|
||||||
_ h: Harness,
|
|
||||||
ttl: TimeInterval = 300,
|
|
||||||
minRetry: TimeInterval = 5,
|
|
||||||
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
|
|
||||||
) -> TorEgressVerifier {
|
|
||||||
TorEgressVerifier(
|
|
||||||
ttl: ttl,
|
|
||||||
minRetryInterval: minRetry,
|
|
||||||
probeTimeout: probeTimeout,
|
|
||||||
now: h.nowProvider(),
|
|
||||||
probe: h.makeProbe()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("verifiedTor allows and is cached within TTL (single probe)")
|
|
||||||
func verifiedIsCached() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
// Second call within TTL must not re-probe.
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("cache expires after TTL and re-probes")
|
|
||||||
func cacheExpires() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
|
|
||||||
h.advance(301)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("notTor refuses (leak detected) and is never cached as allowed")
|
|
||||||
func notTorRefuses() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.notTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
// A subsequent success recovers.
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("unreachable refuses: an unverified egress must not proceed (fail-closed)")
|
|
||||||
func unreachableRefuses() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.unreachable("down"))
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("unreachable-then-reachable recovers via retry")
|
|
||||||
func unreachableRecoversWhenCanaryReturns() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.unreachable("down"))
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
|
||||||
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
|
|
||||||
// Canary comes back; the next probe (after the retry throttle window)
|
|
||||||
// verifies and allows again.
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
h.advance(5)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("cached verifiedTor within TTL allows during a canary blip without re-probing")
|
|
||||||
func cachedVerifiedAllowsDuringCanaryBlip() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
|
|
||||||
// The canary goes down inside the TTL window: the cached positive
|
|
||||||
// verdict is authoritative, no probe runs, traffic stays allowed.
|
|
||||||
h.setResult(.unreachable("down"))
|
|
||||||
h.advance(100)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("TTL expiry + unreachable refuses until a probe succeeds again")
|
|
||||||
func expiredCacheWithUnreachableRefuses() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
|
|
||||||
// Past the TTL the old verdict no longer stands: an unreachable canary
|
|
||||||
// means unverified egress, so connection opens are refused.
|
|
||||||
h.setResult(.unreachable("down"))
|
|
||||||
h.advance(301)
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
|
|
||||||
// A subsequent successful probe restores service.
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("minRetryInterval bounds re-probing while unverified (no canary hammering)")
|
|
||||||
func throttleReprobe() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.unreachable("down"))
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 5)
|
|
||||||
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
// Within minRetry window: reuse last (refusing) decision, no new probe.
|
|
||||||
h.advance(1)
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
// After the window: re-probe.
|
|
||||||
h.advance(5)
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("invalidate clears the synchronous cache snapshot")
|
|
||||||
func invalidateClearsSnapshot() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
await v.invalidate()
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("notTor drops any cached verification snapshot")
|
|
||||||
func notTorClearsSnapshot() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
|
|
||||||
h.setResult(.notTor)
|
|
||||||
h.advance(301)
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("invalidate forces a fresh probe")
|
|
||||||
func invalidateForcesReprobe() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300)
|
|
||||||
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
await v.invalidate()
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("lastProbeResult reflects the most recent outcome")
|
|
||||||
func lastResultTracked() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.notTor)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
_ = await v.verify()
|
|
||||||
#expect(await v.lastProbeResult() == .notTor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Liveness (probe timeout watchdog + invalidate cancellation)
|
|
||||||
|
|
||||||
@Test("a probe that never completes is bounded by probeTimeout and fails closed")
|
|
||||||
func hungProbeIsBoundedByTimeout() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setHanging(true)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0, probeTimeout: 0.05)
|
|
||||||
|
|
||||||
// Without the watchdog this would wedge: waitsForConnectivity can
|
|
||||||
// defer the request timer, leaving the canary bounded only by the
|
|
||||||
// 7-day resource timeout.
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
let last = await v.lastProbeResult()
|
|
||||||
switch last {
|
|
||||||
case .unreachable:
|
|
||||||
break // fail-closed timeout verdict recorded
|
|
||||||
default:
|
|
||||||
Issue.record("expected .unreachable after probe timeout, got \(String(describing: last))")
|
|
||||||
}
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
|
|
||||||
// The hung probe task itself was cancelled (URLSession task would be
|
|
||||||
// torn down), not abandoned.
|
|
||||||
while h.cancelledCount < 1 {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
#expect(h.cancelledCount == 1)
|
|
||||||
|
|
||||||
// The in-flight slot was cleared: the next verify() starts a fresh
|
|
||||||
// probe (does not join the hung one) and recovers.
|
|
||||||
h.setHanging(false)
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("invalidate() cancels the in-flight probe and the awaiting caller fails closed")
|
|
||||||
func invalidateCancelsInFlightProbe() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setHanging(true)
|
|
||||||
// Long (real-time) timeout and throttle: only invalidate() can
|
|
||||||
// unblock the caller, and only invalidate() clearing the throttle
|
|
||||||
// lets the follow-up probe run without advancing the clock.
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 600, probeTimeout: 600)
|
|
||||||
|
|
||||||
let first = Task { await v.verify() }
|
|
||||||
await h.waitUntilProbeCount(atLeast: 1)
|
|
||||||
await v.invalidate()
|
|
||||||
|
|
||||||
// The awaiting caller resolves promptly (no 600s wait) and refuses.
|
|
||||||
#expect(await first.value == false)
|
|
||||||
#expect(v.hasFreshVerification == false)
|
|
||||||
|
|
||||||
// The hung probe observed cancellation (a Tor restart genuinely
|
|
||||||
// shakes the wedged canary request).
|
|
||||||
while h.cancelledCount < 1 {
|
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recovery: a fresh verify() runs a NEW probe — it neither joins the
|
|
||||||
// cancelled one nor inherits its throttle/last-result state.
|
|
||||||
h.setHanging(false)
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("recovery after a hung probe survives invalidate + Tor restart cycle")
|
|
||||||
func hungThenInvalidatedThenRecovers() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setHanging(true)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 5, probeTimeout: 600)
|
|
||||||
|
|
||||||
// Wedge one probe, then simulate a Tor restart mid-flight.
|
|
||||||
let wedged = Task { await v.verify() }
|
|
||||||
await h.waitUntilProbeCount(atLeast: 1)
|
|
||||||
await v.invalidate()
|
|
||||||
#expect(await wedged.value == false)
|
|
||||||
|
|
||||||
// Canary still down right after restart: fresh probe, fail closed —
|
|
||||||
// and the throttle applies to the FRESH result (bounded retry intact).
|
|
||||||
h.setHanging(false)
|
|
||||||
h.setResult(.unreachable("circuit not built"))
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 2)
|
|
||||||
h.advance(1)
|
|
||||||
#expect(await v.verify() == false)
|
|
||||||
#expect(h.probeCount == 2) // throttled, no hammering
|
|
||||||
|
|
||||||
// Canary returns after the retry window: verification recovers.
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
h.advance(5)
|
|
||||||
#expect(await v.verify() == true)
|
|
||||||
#expect(v.hasFreshVerification == true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test("concurrent verify() callers still share a single in-flight probe")
|
|
||||||
func concurrentCallersShareOneProbe() async {
|
|
||||||
let h = Harness()
|
|
||||||
h.setResult(.verifiedTor)
|
|
||||||
h.setGated(true)
|
|
||||||
let v = makeVerifier(h, ttl: 300, minRetry: 0)
|
|
||||||
|
|
||||||
let t1 = Task { await v.verify() }
|
|
||||||
await h.waitUntilProbeCount(atLeast: 1)
|
|
||||||
let t2 = Task { await v.verify() }
|
|
||||||
// Give t2 a chance to join the in-flight probe before releasing it.
|
|
||||||
// Either way the invariant holds: t2 joins the shared probe, or (if
|
|
||||||
// scheduled after completion) hits the fresh TTL cache — exactly one
|
|
||||||
// probe runs.
|
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
|
||||||
h.release()
|
|
||||||
|
|
||||||
#expect(await t1.value == true)
|
|
||||||
#expect(await t2.value == true)
|
|
||||||
#expect(h.probeCount == 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -50,14 +50,19 @@ private final class DefaultTransportProbe: Transport {
|
|||||||
struct ProtocolContractTests {
|
struct ProtocolContractTests {
|
||||||
@Test
|
@Test
|
||||||
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
func commandInfo_exposesAliasesPlaceholdersAndGeoVariants() {
|
||||||
#expect(CommandInfo.message.id == "dm")
|
// Aliases must match what CommandProcessor actually accepts —
|
||||||
#expect(CommandInfo.message.alias == "/dm")
|
// the suggestion panel is the only command-discovery surface.
|
||||||
|
#expect(CommandInfo.message.id == "msg")
|
||||||
|
#expect(CommandInfo.message.alias == "/msg")
|
||||||
#expect(CommandInfo.message.placeholder != nil)
|
#expect(CommandInfo.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(.favorite) == false)
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.help))
|
||||||
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite))
|
// Favorites are rejected by the processor in geohash contexts, so
|
||||||
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite))
|
// they are suggested only in mesh.
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: false).contains(.favorite))
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: true, isGeoDM: false).contains(.favorite) == false)
|
||||||
|
#expect(CommandInfo.all(isGeoPublic: false, isGeoDM: true).contains(.unfavorite) == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -98,8 +98,12 @@ 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)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result?.peerID == peerID)
|
||||||
|
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||||
|
#expect(result?.isDirectAnnounce == true)
|
||||||
|
#expect(result?.isVerified == true)
|
||||||
#expect(recorder.verifySignatureCalls.count == 1)
|
#expect(recorder.verifySignatureCalls.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)
|
||||||
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result?.peerID == peerID)
|
||||||
|
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||||
|
#expect(result?.isVerified == false)
|
||||||
#expect(recorder.verifySignatureCalls.isEmpty)
|
#expect(recorder.verifySignatureCalls.isEmpty)
|
||||||
#expect(recorder.barrierCount == 1)
|
#expect(recorder.barrierCount == 1)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
recorder.signatureValid = false
|
recorder.signatureValid = false
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result?.isVerified == false)
|
||||||
#expect(recorder.verifySignatureCalls.count == 1)
|
#expect(recorder.verifySignatureCalls.count == 1)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,8 +251,9 @@ 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)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
|
|||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result == nil)
|
||||||
expectNoSideEffects(recorder)
|
expectNoSideEffects(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +322,10 @@ 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)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result?.isDirectAnnounce == false)
|
||||||
|
#expect(result?.isVerified == true)
|
||||||
#expect(recorder.upsertCalls.count == 1)
|
#expect(recorder.upsertCalls.count == 1)
|
||||||
#expect(recorder.upsertCalls.first?.isConnected == false)
|
#expect(recorder.upsertCalls.first?.isConnected == false)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
@@ -384,8 +397,9 @@ 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)
|
||||||
|
|
||||||
handler.handle(packet, from: peerID)
|
let result = handler.handle(packet, from: peerID)
|
||||||
|
|
||||||
|
#expect(result?.isVerified == false)
|
||||||
#expect(recorder.upsertCalls.isEmpty)
|
#expect(recorder.upsertCalls.isEmpty)
|
||||||
#expect(recorder.uiEventDeliveries.count == 1)
|
#expect(recorder.uiEventDeliveries.count == 1)
|
||||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||||
|
|||||||
@@ -19,6 +19,79 @@ 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(
|
||||||
|
|||||||
@@ -111,116 +111,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
XCTAssertTrue(connected)
|
XCTAssertTrue(connected)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_connect_whenTorAlreadyReady_waitsForEgressVerificationBeforeCreatingSessions() async {
|
|
||||||
// Tor is bootstrapped, but the egress self-check has no fresh verdict:
|
|
||||||
// the ready path must still queue behind the async egress gate instead
|
|
||||||
// of opening sockets directly.
|
|
||||||
let context = makeContext(
|
|
||||||
permission: .authorized,
|
|
||||||
userTorEnabled: true,
|
|
||||||
torEnforced: true,
|
|
||||||
torIsReady: true,
|
|
||||||
torEgressVerified: false
|
|
||||||
)
|
|
||||||
|
|
||||||
context.manager.connect()
|
|
||||||
|
|
||||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
|
||||||
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
|
|
||||||
|
|
||||||
// Egress verification succeeds (awaitEgressReady returned true, which
|
|
||||||
// implies the verifier now holds a fresh cached verdict).
|
|
||||||
context.torEgressVerified.value = true
|
|
||||||
context.torWaiter.resolve(true)
|
|
||||||
|
|
||||||
let connectedAfterVerification = await waitUntil {
|
|
||||||
context.sessionFactory.requestedURLs.count == self.expectedDefaultRelayCount &&
|
|
||||||
context.manager.relays.allSatisfy(\.isConnected)
|
|
||||||
}
|
|
||||||
XCTAssertTrue(connectedAfterVerification)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_reconnect_requeuesBehindEgressGateWhenVerificationLapses() async {
|
|
||||||
// Reconnect backoff timers call connectToRelay directly; when the
|
|
||||||
// cached egress verification has lapsed by then, the reconnect must go
|
|
||||||
// back through the gate rather than opening a socket.
|
|
||||||
let relayURL = "wss://egress-reconnect.example"
|
|
||||||
let context = makeContext(
|
|
||||||
permission: .denied,
|
|
||||||
userTorEnabled: true,
|
|
||||||
torEnforced: true,
|
|
||||||
torIsReady: true,
|
|
||||||
torEgressVerified: true
|
|
||||||
)
|
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
|
||||||
let connected = await waitUntil {
|
|
||||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
|
||||||
}
|
|
||||||
XCTAssertTrue(connected)
|
|
||||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
|
||||||
|
|
||||||
// The socket drops and the cached verification expires meanwhile.
|
|
||||||
context.torEgressVerified.value = false
|
|
||||||
context.sessionFactory.latestConnection(for: relayURL)?
|
|
||||||
.fail(error: NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut))
|
|
||||||
let reconnectScheduled = await waitUntil { context.scheduler.scheduled.count == 1 }
|
|
||||||
XCTAssertTrue(reconnectScheduled)
|
|
||||||
|
|
||||||
context.scheduler.runNext()
|
|
||||||
let queuedBehindGate = await waitUntil { context.torWaiter.awaitCallCount == 1 }
|
|
||||||
XCTAssertTrue(queuedBehindGate)
|
|
||||||
// No new socket until the egress gate passes.
|
|
||||||
XCTAssertEqual(context.sessionFactory.requestedURLs.count, 1)
|
|
||||||
|
|
||||||
context.torEgressVerified.value = true
|
|
||||||
context.torWaiter.resolve(true)
|
|
||||||
|
|
||||||
let reconnected = await waitUntil {
|
|
||||||
context.sessionFactory.requestedURLs.count == 2 &&
|
|
||||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
|
||||||
}
|
|
||||||
XCTAssertTrue(reconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_connect_egressGateExhaustionSchedulesBoundedRetryAndRecovers() async {
|
|
||||||
// A persistent unverified egress exhausts the wait attempts; a bounded
|
|
||||||
// low-frequency retry must then recover automatically once
|
|
||||||
// verification succeeds (e.g. transient canary outage ends).
|
|
||||||
let relayURL = "wss://egress-gate-retry.example"
|
|
||||||
let context = makeContext(
|
|
||||||
permission: .denied,
|
|
||||||
userTorEnabled: true,
|
|
||||||
torEnforced: true,
|
|
||||||
torIsReady: true,
|
|
||||||
torEgressVerified: false
|
|
||||||
)
|
|
||||||
|
|
||||||
context.manager.ensureConnections(to: [relayURL])
|
|
||||||
for _ in 0..<TransportConfig.nostrTorReadyMaxWaitAttempts {
|
|
||||||
context.torWaiter.resolve(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail-closed, with a single bounded-cadence retry scheduled.
|
|
||||||
XCTAssertTrue(context.sessionFactory.requestedURLs.isEmpty)
|
|
||||||
XCTAssertEqual(context.scheduler.scheduled.count, 1)
|
|
||||||
XCTAssertEqual(context.scheduler.scheduled.first?.delay, TransportConfig.nostrTorGateRetrySeconds)
|
|
||||||
|
|
||||||
// The outage ends before the retry fires.
|
|
||||||
let attemptsBefore = context.torWaiter.awaitCallCount
|
|
||||||
context.scheduler.runNext()
|
|
||||||
let regated = await waitUntil { context.torWaiter.awaitCallCount == attemptsBefore + 1 }
|
|
||||||
XCTAssertTrue(regated)
|
|
||||||
|
|
||||||
context.torEgressVerified.value = true
|
|
||||||
context.torWaiter.resolve(true)
|
|
||||||
|
|
||||||
let recovered = await waitUntil {
|
|
||||||
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
|
|
||||||
}
|
|
||||||
XCTAssertTrue(recovered)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
func test_subscribe_unblocksDeferredEOSEWhenTorWaitAttemptsExhausted() async {
|
||||||
let relayURL = "wss://tor-eose-unblock.example"
|
let relayURL = "wss://tor-eose-unblock.example"
|
||||||
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
|
||||||
@@ -1559,7 +1449,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
userTorEnabled: Bool = false,
|
userTorEnabled: Bool = false,
|
||||||
torEnforced: Bool = false,
|
torEnforced: Bool = false,
|
||||||
torIsReady: Bool = true,
|
torIsReady: Bool = true,
|
||||||
torEgressVerified: Bool = true,
|
|
||||||
torIsForeground: Bool = true,
|
torIsForeground: Bool = true,
|
||||||
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
jitterUnit: @escaping () -> Double = { 0.5 } // 0.5 -> jitter factor 1.0 (no jitter)
|
||||||
) -> RelayManagerTestContext {
|
) -> RelayManagerTestContext {
|
||||||
@@ -1569,7 +1458,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
let scheduler = MockRelayScheduler()
|
let scheduler = MockRelayScheduler()
|
||||||
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
let clock = MutableClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
let torWaiter = MockTorWaiter(isReady: torIsReady)
|
||||||
let torEgressVerifiedFlag = MutableBool(value: torEgressVerified)
|
|
||||||
let torForeground = MutableBool(value: torIsForeground)
|
let torForeground = MutableBool(value: torIsForeground)
|
||||||
let activationFlag = MutableBool(value: activationAllowed)
|
let activationFlag = MutableBool(value: activationAllowed)
|
||||||
let manager = NostrRelayManager(
|
let manager = NostrRelayManager(
|
||||||
@@ -1582,7 +1470,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
locationPermissionPublisher: permissionSubject.eraseToAnyPublisher(),
|
||||||
torEnforced: { torEnforced },
|
torEnforced: { torEnforced },
|
||||||
torIsReady: { torWaiter.isReady },
|
torIsReady: { torWaiter.isReady },
|
||||||
torEgressVerified: { torEgressVerifiedFlag.value },
|
|
||||||
torIsForeground: { torForeground.value },
|
torIsForeground: { torForeground.value },
|
||||||
awaitTorReady: torWaiter.await(completion:),
|
awaitTorReady: torWaiter.await(completion:),
|
||||||
makeSession: { sessionFactory },
|
makeSession: { sessionFactory },
|
||||||
@@ -1602,7 +1489,6 @@ final class NostrRelayManagerTests: XCTestCase {
|
|||||||
clock: clock,
|
clock: clock,
|
||||||
activationAllowed: activationFlag,
|
activationAllowed: activationFlag,
|
||||||
torWaiter: torWaiter,
|
torWaiter: torWaiter,
|
||||||
torEgressVerified: torEgressVerifiedFlag,
|
|
||||||
torForeground: torForeground
|
torForeground: torForeground
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1657,7 +1543,6 @@ private struct RelayManagerTestContext {
|
|||||||
let clock: MutableClock
|
let clock: MutableClock
|
||||||
let activationAllowed: MutableBool
|
let activationAllowed: MutableBool
|
||||||
let torWaiter: MockTorWaiter
|
let torWaiter: MockTorWaiter
|
||||||
let torEgressVerified: MutableBool
|
|
||||||
let torForeground: MutableBool
|
let torForeground: MutableBool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,44 @@ struct UnifiedPeerServiceTests {
|
|||||||
|
|
||||||
#expect(service.isBlocked(peerID))
|
#expect(service.isBlocked(peerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func setBlocked_persistsByFingerprintAndToggles() async {
|
||||||
|
let transport = MockTransport()
|
||||||
|
let identity = TestIdentityManager()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
|
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||||
|
|
||||||
|
let peerID = PeerID(str: "00000000000000EE")
|
||||||
|
let fingerprint = "fp-target"
|
||||||
|
transport.peerFingerprints[peerID] = fingerprint
|
||||||
|
|
||||||
|
// Blocking resolves and persists by the peer's fingerprint.
|
||||||
|
let resolved = service.setBlocked(peerID, blocked: true)
|
||||||
|
#expect(resolved == fingerprint)
|
||||||
|
#expect(identity.isBlocked(fingerprint: fingerprint))
|
||||||
|
#expect(service.isBlocked(peerID))
|
||||||
|
|
||||||
|
// Unblocking clears it against the same identity.
|
||||||
|
let unresolved = service.setBlocked(peerID, blocked: false)
|
||||||
|
#expect(unresolved == fingerprint)
|
||||||
|
#expect(!identity.isBlocked(fingerprint: fingerprint))
|
||||||
|
#expect(!service.isBlocked(peerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func setBlocked_unknownIdentityReturnsNil() async {
|
||||||
|
let transport = MockTransport()
|
||||||
|
let identity = TestIdentityManager()
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||||
|
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
|
||||||
|
|
||||||
|
// No fingerprint resolvable for this peer (offline & unknown).
|
||||||
|
let peerID = PeerID(str: "00000000000000FF")
|
||||||
|
|
||||||
|
#expect(service.setBlocked(peerID, blocked: true) == nil)
|
||||||
|
#expect(!service.isBlocked(peerID))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ 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"
|
||||||
|
|||||||
@@ -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 = "test payload".data(using: .utf8)!,
|
payload: Data = Data("test payload".utf8),
|
||||||
signature: Data? = nil,
|
signature: Data? = nil,
|
||||||
ttl: UInt8 = 3
|
ttl: UInt8 = 3
|
||||||
) -> BitchatPacket {
|
) -> BitchatPacket {
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
type: type,
|
type: type,
|
||||||
senderID: senderID.id.data(using: .utf8)!,
|
senderID: Data(senderID.id.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,
|
||||||
|
|||||||
@@ -556,11 +556,19 @@ 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(
|
||||||
@@ -594,12 +602,14 @@ struct ViewSmokeTests {
|
|||||||
_ = mount(voiceNoteView)
|
_ = mount(voiceNoteView)
|
||||||
|
|
||||||
let bins = await withCheckedContinuation { continuation in
|
let bins = await withCheckedContinuation { continuation in
|
||||||
WaveformCache.shared.waveform(for: audioURL, bins: 16) { values in
|
WaveformCache.shared.waveform(for: waveformProbeURL, bins: 16) { values in
|
||||||
continuation.resume(returning: values)
|
continuation.resume(returning: values)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
playback.loadDuration()
|
playback.loadDuration()
|
||||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
// loadDuration hops through a background queue and back to main; poll
|
||||||
|
// instead of a fixed sleep so a loaded runner can't outlast the wait.
|
||||||
|
_ = await TestHelpers.waitUntil({ playback.duration > 0 })
|
||||||
playback.seek(to: 1.25)
|
playback.seek(to: 1.25)
|
||||||
playback.stop()
|
playback.stop()
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
VoiceNotePlaybackCoordinator.shared.activate(playback)
|
||||||
@@ -607,7 +617,7 @@ struct ViewSmokeTests {
|
|||||||
await VoiceRecorder.shared.cancelRecording()
|
await VoiceRecorder.shared.cancelRecording()
|
||||||
|
|
||||||
#expect(bins.count == 16)
|
#expect(bins.count == 16)
|
||||||
#expect(WaveformCache.shared.cachedWaveform(for: audioURL)?.count == 16)
|
#expect(WaveformCache.shared.cachedWaveform(for: waveformProbeURL)?.count == 16)
|
||||||
#expect(playback.duration > 0)
|
#expect(playback.duration > 0)
|
||||||
#expect(playback.progress == 0)
|
#expect(playback.progress == 0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import struct Foundation.Data
|
|||||||
struct XChaCha20Poly1305CompatTests {
|
struct XChaCha20Poly1305CompatTests {
|
||||||
|
|
||||||
@Test func sealAndOpenRoundtrip() throws {
|
@Test func sealAndOpenRoundtrip() throws {
|
||||||
let plaintext = "Hello, XChaCha20-Poly1305!".data(using: .utf8)!
|
let plaintext = Data("Hello, XChaCha20-Poly1305!".utf8)
|
||||||
let key = Data(repeating: 0x42, count: 32)
|
let 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 = "Secret message".data(using: .utf8)!
|
let plaintext = Data("Secret message".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 = "additional authenticated data".data(using: .utf8)!
|
let aad = Data("additional authenticated data".utf8)
|
||||||
|
|
||||||
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: plaintext, key: key, nonce24: nonce, aad: aad)
|
let 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 = "Same plaintext".data(using: .utf8)!
|
let plaintext = Data("Same plaintext".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Test".data(using: .utf8)!
|
let plaintext = Data("Test".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 = "Secret".data(using: .utf8)!
|
let plaintext = Data("Secret".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 = "Secret".data(using: .utf8)!
|
let plaintext = Data("Secret".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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +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"
|
||||||
"TorEgressVerifier.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.
|
||||||
@@ -43,6 +42,6 @@ let package = Package(
|
|||||||
.binaryTarget(
|
.binaryTarget(
|
||||||
name: "arti",
|
name: "arti",
|
||||||
path: "Frameworks/arti.xcframework"
|
path: "Frameworks/arti.xcframework"
|
||||||
),
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,365 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Runtime self-check that the proxied `URLSession` egress is *actually* routed
|
|
||||||
/// through Tor — defense-in-depth for the case where a platform silently ignores
|
|
||||||
/// `URLSessionConfiguration.connectionProxyDictionary` SOCKS settings and lets
|
|
||||||
/// traffic egress directly (leaking the real IP while Tor appears enabled).
|
|
||||||
///
|
|
||||||
/// Runtime verification (see `scripts/tor-egress-verification/`) showed that
|
|
||||||
/// macOS and the iOS simulator DO honor the SOCKS proxy for both plain HTTPS and
|
|
||||||
/// `URLSessionWebSocketTask`, and that the proxied session is fail-closed (every
|
|
||||||
/// request errors when the SOCKS proxy is down). Apple does not officially
|
|
||||||
/// support SOCKS for URLSession on iOS, so on a physical device the behavior is
|
|
||||||
/// not contractually guaranteed. This verifier closes that gap: before relay
|
|
||||||
/// connections are opened under enforced Tor, it performs a canary request whose
|
|
||||||
/// response positively reports whether the egress hit the network via Tor.
|
|
||||||
///
|
|
||||||
/// Policy (`verify()` return value) — fail-closed on unverified egress:
|
|
||||||
/// - `.verifiedTor` → allow, and cache the positive result for `ttl`.
|
|
||||||
/// - `.notTor` → REFUSE, and drop any cached verification. The canary
|
|
||||||
/// reached the internet but the exit is NOT a Tor node:
|
|
||||||
/// a real leak. Never allow relays.
|
|
||||||
/// - `.unreachable` → REFUSE (egress unverified). The canary itself failed
|
|
||||||
/// (endpoint down / circuit not built), so we cannot tell
|
|
||||||
/// whether the platform honored the SOCKS proxy — the
|
|
||||||
/// exact ambiguity this verifier exists to resolve.
|
|
||||||
/// Unverified traffic must not proceed on the
|
|
||||||
/// enforced-Tor path.
|
|
||||||
///
|
|
||||||
/// TTL / retry semantics:
|
|
||||||
/// - A `verifiedTor` verdict allows connection *opens* for `ttl` without
|
|
||||||
/// re-probing, so a brief canary blip inside the TTL window does not take
|
|
||||||
/// relays offline (a fresh positive verdict is authoritative for the
|
|
||||||
/// window). Already-open sockets are never torn down by verification —
|
|
||||||
/// they were opened under a verified egress and the proxied session is
|
|
||||||
/// fail-closed by construction.
|
|
||||||
/// - After TTL expiry (or `invalidate()` on Tor restart/dormant/shutdown),
|
|
||||||
/// the next `verify()` re-probes; while the canary stays `.unreachable`,
|
|
||||||
/// new connection opens are refused until a probe succeeds again.
|
|
||||||
/// - Probe cadence is bounded: at most one probe per `minRetryInterval`
|
|
||||||
/// (callers within the window reuse the last decision), and concurrent
|
|
||||||
/// `verify()` calls share one in-flight probe. Recovery from a transient
|
|
||||||
/// canary outage is automatic: callers that keep retrying (relay connect
|
|
||||||
/// gate, GeoRelayDirectory backoff) re-probe and succeed once the canary
|
|
||||||
/// is reachable again.
|
|
||||||
///
|
|
||||||
/// Liveness:
|
|
||||||
/// - Every probe is hard-bounded by `probeTimeout` via an independent async
|
|
||||||
/// watchdog. The proxied session sets `waitsForConnectivity = true`, which
|
|
||||||
/// can defer the per-request timer indefinitely, leaving the request
|
|
||||||
/// bounded only by the default 7-day resource timeout — without the
|
|
||||||
/// watchdog a hung canary would wedge every subsequent `verify()` caller.
|
|
||||||
/// On timeout the probe task is cancelled (cooperatively cancelling the
|
|
||||||
/// underlying `URLSessionTask`), the verdict is the fail-closed
|
|
||||||
/// `.unreachable`, and the in-flight slot is cleared so the next
|
|
||||||
/// `verify()` (after the retry throttle) starts a fresh probe.
|
|
||||||
/// - `invalidate()` cancels any in-flight probe and clears all cached state
|
|
||||||
/// (including the retry throttle), so a Tor restart/dormant/shutdown
|
|
||||||
/// genuinely resets the verifier: a hung probe cannot survive it.
|
|
||||||
///
|
|
||||||
/// The probe is injectable so the policy/caching logic is unit-tested without a
|
|
||||||
/// live network (see `TorEgressVerifierTests`).
|
|
||||||
public actor TorEgressVerifier {
|
|
||||||
public enum ProbeResult: Equatable, Sendable {
|
|
||||||
/// Canary succeeded and the exit is a Tor node.
|
|
||||||
case verifiedTor
|
|
||||||
/// Canary succeeded but the exit is NOT Tor — a direct-egress leak.
|
|
||||||
case notTor
|
|
||||||
/// Canary could not complete (endpoint down, no circuit, parse error).
|
|
||||||
case unreachable(String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hard upper bound for a single canary probe, enforced independently of
|
|
||||||
/// URLSession timers (see the "Liveness" section of the type doc). Matches
|
|
||||||
/// the live probe's per-request timeout.
|
|
||||||
public static let defaultProbeTimeout: TimeInterval = 20
|
|
||||||
|
|
||||||
private let probe: @Sendable () async -> ProbeResult
|
|
||||||
private let now: @Sendable () -> Date
|
|
||||||
private let ttl: TimeInterval
|
|
||||||
/// Minimum spacing between probes when not currently verified, so a
|
|
||||||
/// persistent `.unreachable` cannot hammer the canary endpoint on every
|
|
||||||
/// reconnect burst.
|
|
||||||
private let minRetryInterval: TimeInterval
|
|
||||||
/// Outer wall-clock bound on a single probe (watchdog; fail-closed).
|
|
||||||
private let probeTimeout: TimeInterval
|
|
||||||
|
|
||||||
private var lastVerifiedAt: Date?
|
|
||||||
private var lastProbeAt: Date?
|
|
||||||
private var lastResult: ProbeResult?
|
|
||||||
private var inFlight: Task<Bool, Never>?
|
|
||||||
/// Bumped whenever a new probe starts or `invalidate()` runs. A completing
|
|
||||||
/// probe only records its outcome (cache/throttle) and clears `inFlight`
|
|
||||||
/// if its generation is still current, so a cancelled/superseded probe
|
|
||||||
/// cannot clobber state owned by a fresh one (actor-reentrancy safety).
|
|
||||||
private var probeGeneration = 0
|
|
||||||
|
|
||||||
/// Lock-protected mirror of "verified within TTL" so synchronous gates
|
|
||||||
/// (e.g. `NostrRelayManager`'s connect path) can consult the cache without
|
|
||||||
/// awaiting the actor.
|
|
||||||
private let verifiedSnapshot = VerifiedSnapshot()
|
|
||||||
|
|
||||||
private final class VerifiedSnapshot: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var verifiedUntil: Date?
|
|
||||||
|
|
||||||
func update(_ until: Date?) {
|
|
||||||
lock.lock()
|
|
||||||
verifiedUntil = until
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func isFresh(at date: Date) -> Bool {
|
|
||||||
lock.lock()
|
|
||||||
defer { lock.unlock() }
|
|
||||||
guard let verifiedUntil else { return false }
|
|
||||||
return date < verifiedUntil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public init(
|
|
||||||
ttl: TimeInterval,
|
|
||||||
minRetryInterval: TimeInterval = 5.0,
|
|
||||||
probeTimeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout,
|
|
||||||
now: @escaping @Sendable () -> Date = Date.init,
|
|
||||||
probe: @escaping @Sendable () async -> ProbeResult
|
|
||||||
) {
|
|
||||||
self.ttl = ttl
|
|
||||||
self.minRetryInterval = minRetryInterval
|
|
||||||
self.probeTimeout = probeTimeout
|
|
||||||
self.now = now
|
|
||||||
self.probe = probe
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop any cached verification (e.g. after a Tor restart or when the
|
|
||||||
/// network path changes) AND cancel any in-flight probe. The next
|
|
||||||
/// `verify()` starts a fresh probe — it neither joins the cancelled one
|
|
||||||
/// nor is throttled by its outcome, so a probe hung from before a Tor
|
|
||||||
/// restart cannot wedge callers after it.
|
|
||||||
public func invalidate() {
|
|
||||||
probeGeneration += 1
|
|
||||||
inFlight?.cancel()
|
|
||||||
inFlight = nil
|
|
||||||
lastVerifiedAt = nil
|
|
||||||
lastProbeAt = nil
|
|
||||||
lastResult = nil
|
|
||||||
verifiedSnapshot.update(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most recent probe outcome, for diagnostics/tests.
|
|
||||||
public func lastProbeResult() -> ProbeResult? { lastResult }
|
|
||||||
|
|
||||||
/// Synchronous view of the cache: `true` while a `verifiedTor` verdict is
|
|
||||||
/// within its TTL. Callers that get `false` must route through the async
|
|
||||||
/// `verify()` gate (which probes) before opening connections.
|
|
||||||
public nonisolated var hasFreshVerification: Bool {
|
|
||||||
verifiedSnapshot.isFresh(at: now())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns `true` only when the proxied egress is verified to exit via Tor
|
|
||||||
/// (a fresh probe or a cached `verifiedTor` verdict within TTL). Returns
|
|
||||||
/// `false` when a non-Tor egress was positively detected *or* when the
|
|
||||||
/// egress could not be verified. See the type doc for the full policy.
|
|
||||||
public func verify() async -> Bool {
|
|
||||||
if isFreshlyVerified() { return true }
|
|
||||||
// Throttle re-probes when the last attempt did not verify.
|
|
||||||
if let last = lastProbeAt,
|
|
||||||
let result = lastResult,
|
|
||||||
now().timeIntervalSince(last) < minRetryInterval {
|
|
||||||
return decision(for: result)
|
|
||||||
}
|
|
||||||
if let inFlight { return await inFlight.value }
|
|
||||||
|
|
||||||
probeGeneration += 1
|
|
||||||
let generation = probeGeneration
|
|
||||||
let task = Task<Bool, Never> { await self.runProbe(generation: generation) }
|
|
||||||
inFlight = task
|
|
||||||
let allowed = await task.value
|
|
||||||
// Only clear the slot if this probe is still the current one: an
|
|
||||||
// `invalidate()` while we were suspended has already cleared it and a
|
|
||||||
// newer probe may occupy it (do not clobber the fresh task).
|
|
||||||
if probeGeneration == generation { inFlight = nil }
|
|
||||||
return allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isFreshlyVerified() -> Bool {
|
|
||||||
guard let last = lastVerifiedAt else { return false }
|
|
||||||
return now().timeIntervalSince(last) < ttl
|
|
||||||
}
|
|
||||||
|
|
||||||
private func decision(for result: ProbeResult) -> Bool {
|
|
||||||
switch result {
|
|
||||||
case .verifiedTor: return true
|
|
||||||
// Fail closed: both a positively detected leak and an unverifiable
|
|
||||||
// egress refuse connections. Only a fresh `verifiedTor` allows.
|
|
||||||
case .unreachable, .notTor: return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runProbe(generation: Int) async -> Bool {
|
|
||||||
let result = await boundedProbe()
|
|
||||||
// Superseded by `invalidate()` (Tor restart/dormant/shutdown) while the
|
|
||||||
// probe ran: its verdict predates the reset, so discard it — recording
|
|
||||||
// it would re-seed the throttle/cache that invalidate() just cleared.
|
|
||||||
// Fail closed for the callers that were awaiting this probe.
|
|
||||||
guard generation == probeGeneration else { return false }
|
|
||||||
lastProbeAt = now()
|
|
||||||
lastResult = result
|
|
||||||
switch result {
|
|
||||||
case .verifiedTor:
|
|
||||||
lastVerifiedAt = now()
|
|
||||||
verifiedSnapshot.update(now().addingTimeInterval(ttl))
|
|
||||||
return true
|
|
||||||
case .notTor:
|
|
||||||
lastVerifiedAt = nil
|
|
||||||
verifiedSnapshot.update(nil)
|
|
||||||
SecureLogger.error(
|
|
||||||
"🧅 Tor egress self-check FAILED: request exited via a NON-Tor address — refusing relay connections (possible IP leak)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
case .unreachable(let why):
|
|
||||||
// Note: a probe only runs when no fresh cached verdict exists, so
|
|
||||||
// there is no still-valid cache to preserve or drop here.
|
|
||||||
SecureLogger.warning(
|
|
||||||
"🧅 Tor egress self-check could not complete (\(why)) — egress UNVERIFIED; refusing relay connections until the canary succeeds (bounded retry)",
|
|
||||||
category: .session
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs the injected probe raced against `probeTimeout`, guaranteeing a
|
|
||||||
/// result in bounded time regardless of URLSession timer behavior (the
|
|
||||||
/// proxied session's `waitsForConnectivity` can defer the per-request
|
|
||||||
/// timeout indefinitely). Whichever side loses the race is cancelled:
|
|
||||||
/// - on timeout, the probe task is cancelled (URLSession's async APIs
|
|
||||||
/// cancel the underlying `URLSessionTask` cooperatively) and the result
|
|
||||||
/// is the fail-closed `.unreachable`;
|
|
||||||
/// - on completion, the watchdog's sleep is cancelled so no timer lingers.
|
|
||||||
/// Cancelling the enclosing task (`invalidate()`) resolves immediately as
|
|
||||||
/// `.unreachable` and cancels both sides.
|
|
||||||
///
|
|
||||||
/// `nonisolated` so the race body never re-enters the actor; it touches
|
|
||||||
/// only immutable `Sendable` state.
|
|
||||||
nonisolated private func boundedProbe() async -> ProbeResult {
|
|
||||||
let probe = self.probe
|
|
||||||
let timeout = self.probeTimeout
|
|
||||||
let race = ProbeRace()
|
|
||||||
return await withTaskCancellationHandler {
|
|
||||||
await withCheckedContinuation { (continuation: CheckedContinuation<ProbeResult, Never>) in
|
|
||||||
race.install(continuation)
|
|
||||||
let probeTask = Task { race.finish(await probe()) }
|
|
||||||
let watchdog = Task {
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000))
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
race.finish(.unreachable("probe timed out after \(Int(timeout))s"))
|
|
||||||
}
|
|
||||||
race.register(probeTask: probeTask, watchdog: watchdog)
|
|
||||||
}
|
|
||||||
} onCancel: {
|
|
||||||
race.finish(.unreachable("probe cancelled"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve-once rendezvous for the probe/watchdog race. Lock-protected
|
|
||||||
/// (never held across an await); the first `finish()` wins, resumes the
|
|
||||||
/// continuation exactly once, and cancels both tasks.
|
|
||||||
private final class ProbeRace: @unchecked Sendable {
|
|
||||||
private let lock = NSLock()
|
|
||||||
private var continuation: CheckedContinuation<ProbeResult, Never>?
|
|
||||||
private var pendingResult: ProbeResult?
|
|
||||||
private var resolved = false
|
|
||||||
private var probeTask: Task<Void, Never>?
|
|
||||||
private var watchdog: Task<Void, Never>?
|
|
||||||
|
|
||||||
func install(_ continuation: CheckedContinuation<ProbeResult, Never>) {
|
|
||||||
lock.lock()
|
|
||||||
if let result = pendingResult {
|
|
||||||
// finish() ran before the continuation existed (e.g. the
|
|
||||||
// enclosing task was already cancelled): resolve immediately.
|
|
||||||
pendingResult = nil
|
|
||||||
lock.unlock()
|
|
||||||
continuation.resume(returning: result)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.continuation = continuation
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func register(probeTask: Task<Void, Never>, watchdog: Task<Void, Never>) {
|
|
||||||
lock.lock()
|
|
||||||
if resolved {
|
|
||||||
lock.unlock()
|
|
||||||
probeTask.cancel()
|
|
||||||
watchdog.cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.probeTask = probeTask
|
|
||||||
self.watchdog = watchdog
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish(_ result: ProbeResult) {
|
|
||||||
lock.lock()
|
|
||||||
guard !resolved else { lock.unlock(); return }
|
|
||||||
resolved = true
|
|
||||||
let continuation = self.continuation
|
|
||||||
self.continuation = nil
|
|
||||||
if continuation == nil { pendingResult = result }
|
|
||||||
let probeTask = self.probeTask
|
|
||||||
let watchdog = self.watchdog
|
|
||||||
self.probeTask = nil
|
|
||||||
self.watchdog = nil
|
|
||||||
lock.unlock()
|
|
||||||
probeTask?.cancel()
|
|
||||||
watchdog?.cancel()
|
|
||||||
continuation?.resume(returning: result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Live probe
|
|
||||||
|
|
||||||
public extension TorEgressVerifier {
|
|
||||||
/// Default canary: fetch Tor Project's connectivity check API through the
|
|
||||||
/// shared proxied session and assert `IsTor == true`. Because the response
|
|
||||||
/// is served from the *exit's* vantage point, a silent direct egress is
|
|
||||||
/// caught here as `.notTor`. `check.torproject.org` is clearnet, so this
|
|
||||||
/// works without onion-service support.
|
|
||||||
///
|
|
||||||
/// Follow-up (see PR): make the canary endpoint configurable and add an
|
|
||||||
/// onion-service canary so verification does not depend on a single host.
|
|
||||||
/// Note: the per-request `timeoutInterval` below is best-effort only — the
|
|
||||||
/// proxied session's `waitsForConnectivity` can defer it. The authoritative
|
|
||||||
/// bound is the verifier's `probeTimeout` watchdog, whose cancellation
|
|
||||||
/// propagates into `session.data(for:)` and cancels the URLSessionTask.
|
|
||||||
static func liveProbe(
|
|
||||||
endpoint: URL = URL(string: "https://check.torproject.org/api/ip")!,
|
|
||||||
timeout: TimeInterval = TorEgressVerifier.defaultProbeTimeout
|
|
||||||
) -> @Sendable () async -> ProbeResult {
|
|
||||||
return {
|
|
||||||
var request = URLRequest(url: endpoint)
|
|
||||||
request.timeoutInterval = timeout
|
|
||||||
request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
|
|
||||||
let session = TorURLSession.shared.session
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(for: request)
|
|
||||||
guard let http = response as? HTTPURLResponse,
|
|
||||||
(200..<300).contains(http.statusCode) else {
|
|
||||||
return .unreachable("http status \((response as? HTTPURLResponse)?.statusCode ?? -1)")
|
|
||||||
}
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
return .unreachable("unparseable canary response")
|
|
||||||
}
|
|
||||||
if let isTor = json["IsTor"] as? Bool {
|
|
||||||
return isTor ? .verifiedTor : .notTor
|
|
||||||
}
|
|
||||||
return .unreachable("canary response missing IsTor")
|
|
||||||
} catch {
|
|
||||||
return .unreachable(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,14 +59,6 @@ public final class TorManager: ObservableObject {
|
|||||||
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
private var socksReady: Bool = false { didSet { recomputeReady() } }
|
||||||
private var restarting: Bool = false
|
private var restarting: Bool = false
|
||||||
|
|
||||||
/// Runtime egress self-check: proves the proxied session actually exits via
|
|
||||||
/// Tor before relay connections are opened (defense-in-depth against a
|
|
||||||
/// platform silently ignoring the SOCKS proxy). Cached for a few minutes.
|
|
||||||
public let egressVerifier = TorEgressVerifier(
|
|
||||||
ttl: 300,
|
|
||||||
probe: TorEgressVerifier.liveProbe()
|
|
||||||
)
|
|
||||||
|
|
||||||
// Whether the app must enforce Tor for all connections (fail-closed).
|
// Whether the app must enforce Tor for all connections (fail-closed).
|
||||||
public var torEnforced: Bool {
|
public var torEnforced: Bool {
|
||||||
#if BITCHAT_DEV_ALLOW_CLEARNET
|
#if BITCHAT_DEV_ALLOW_CLEARNET
|
||||||
@@ -133,32 +125,6 @@ public final class TorManager: ObservableObject {
|
|||||||
return await MainActor.run(body: { self.networkPermitted })
|
return await MainActor.run(body: { self.networkPermitted })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronous, cached view of the egress gate: `true` while a positive
|
|
||||||
/// egress verification is within its TTL (or when Tor is not enforced).
|
|
||||||
/// When this is `false`, callers must route through `awaitEgressReady()`
|
|
||||||
/// (which probes) before opening any connection — never connect directly.
|
|
||||||
public var isEgressVerified: Bool {
|
|
||||||
guard torEnforced else { return true }
|
|
||||||
return egressVerifier.hasFreshVerification
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Like `awaitReady`, but additionally requires that a canary request
|
|
||||||
/// through the proxied session positively verifies Tor egress. Returns
|
|
||||||
/// `false` if Tor never became ready, or if the egress self-check could
|
|
||||||
/// not positively verify a Tor exit (non-Tor egress detected, or canary
|
|
||||||
/// unreachable → unverified). Callers must fail closed on `false` — never
|
|
||||||
/// fall back to a direct connection.
|
|
||||||
nonisolated
|
|
||||||
public func awaitEgressReady(timeout: TimeInterval = 75.0) async -> Bool {
|
|
||||||
let ready = await awaitReady(timeout: timeout)
|
|
||||||
guard ready else { return false }
|
|
||||||
// Clearnet dev builds don't route through Tor, so the canary would
|
|
||||||
// (correctly) report non-Tor; skip it there.
|
|
||||||
let enforced = await MainActor.run { self.torEnforced }
|
|
||||||
guard enforced else { return true }
|
|
||||||
return await egressVerifier.verify()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Filesystem
|
// MARK: - Filesystem
|
||||||
|
|
||||||
func dataDirectoryURL() -> URL? {
|
func dataDirectoryURL() -> URL? {
|
||||||
@@ -359,8 +325,6 @@ public final class TorManager: ObservableObject {
|
|||||||
self.socksReady = false
|
self.socksReady = false
|
||||||
self.isStarting = false
|
self.isStarting = false
|
||||||
}
|
}
|
||||||
// Force a fresh egress self-check once Tor comes back.
|
|
||||||
Task { await egressVerifier.invalidate() }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func shutdownCompletely() {
|
public func shutdownCompletely() {
|
||||||
@@ -389,7 +353,6 @@ public final class TorManager: ObservableObject {
|
|||||||
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
// Note: Don't clear startedAt here - it will be set fresh on next startIfNeeded()
|
||||||
// Clearing it here races with startup and defeats the grace period
|
// Clearing it here races with startup and defeats the grace period
|
||||||
}
|
}
|
||||||
await self.egressVerifier.invalidate()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,8 +368,6 @@ public final class TorManager: ObservableObject {
|
|||||||
self.isDormant = false
|
self.isDormant = false
|
||||||
self.lastRestartAt = Date()
|
self.lastRestartAt = Date()
|
||||||
}
|
}
|
||||||
// New Arti instance means new circuits; re-verify egress after restart.
|
|
||||||
await egressVerifier.invalidate()
|
|
||||||
|
|
||||||
_ = arti_stop()
|
_ = arti_stop()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
//
|
||||||
|
// CourierEnvelope.swift
|
||||||
|
// BitFoundation
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
private import CryptoKit
|
||||||
|
|
||||||
|
/// TLV payload for store-and-forward courier envelopes.
|
||||||
|
///
|
||||||
|
/// A courier envelope lets a mutual favorite physically carry an encrypted
|
||||||
|
/// message to a peer who is currently offline. The envelope is opaque to the
|
||||||
|
/// courier: the only routing information is a rotating recipient tag derived
|
||||||
|
/// from the recipient's Noise static public key and the UTC day, so envelopes
|
||||||
|
/// addressed to the same peer on different days do not correlate for
|
||||||
|
/// observers who don't already know that peer's public key.
|
||||||
|
public struct CourierEnvelope: Equatable {
|
||||||
|
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
|
||||||
|
public let recipientTag: Data
|
||||||
|
/// Milliseconds since epoch after which the envelope must be discarded.
|
||||||
|
public let expiry: UInt64
|
||||||
|
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
|
||||||
|
public let ciphertext: Data
|
||||||
|
|
||||||
|
public static let tagLength = 16
|
||||||
|
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||||
|
public static let maxCiphertextBytes = 16 * 1024
|
||||||
|
/// Matches the outbox retention policy in MessageRouter.
|
||||||
|
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
|
private enum TLVType: UInt8 {
|
||||||
|
case recipientTag = 0x01
|
||||||
|
case expiry = 0x02
|
||||||
|
case ciphertext = 0x03
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
|
||||||
|
self.recipientTag = recipientTag
|
||||||
|
self.expiry = expiry
|
||||||
|
self.ciphertext = ciphertext
|
||||||
|
}
|
||||||
|
|
||||||
|
public var isExpired: Bool {
|
||||||
|
isExpired(at: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func isExpired(at date: Date) -> Bool {
|
||||||
|
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode() -> Data? {
|
||||||
|
guard recipientTag.count == Self.tagLength else { return nil }
|
||||||
|
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
|
||||||
|
|
||||||
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
|
var big = value.bigEndian
|
||||||
|
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded = Data()
|
||||||
|
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
|
||||||
|
|
||||||
|
encoded.append(TLVType.recipientTag.rawValue)
|
||||||
|
appendBE(UInt16(recipientTag.count), into: &encoded)
|
||||||
|
encoded.append(recipientTag)
|
||||||
|
|
||||||
|
encoded.append(TLVType.expiry.rawValue)
|
||||||
|
appendBE(UInt16(8), into: &encoded)
|
||||||
|
appendBE(expiry, into: &encoded)
|
||||||
|
|
||||||
|
encoded.append(TLVType.ciphertext.rawValue)
|
||||||
|
appendBE(UInt16(ciphertext.count), into: &encoded)
|
||||||
|
encoded.append(ciphertext)
|
||||||
|
|
||||||
|
return encoded
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func decode(_ data: Data) -> CourierEnvelope? {
|
||||||
|
var cursor = data.startIndex
|
||||||
|
let end = data.endIndex
|
||||||
|
|
||||||
|
var recipientTag: Data?
|
||||||
|
var expiry: UInt64?
|
||||||
|
var ciphertext: Data?
|
||||||
|
|
||||||
|
while cursor < end {
|
||||||
|
let typeRaw = data[cursor]
|
||||||
|
cursor = data.index(after: cursor)
|
||||||
|
|
||||||
|
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
|
||||||
|
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||||
|
cursor = data.index(cursor, offsetBy: 2)
|
||||||
|
guard data.distance(from: cursor, to: end) >= length else { return nil }
|
||||||
|
let value = data[cursor..<data.index(cursor, offsetBy: length)]
|
||||||
|
cursor = data.index(cursor, offsetBy: length)
|
||||||
|
|
||||||
|
switch TLVType(rawValue: typeRaw) {
|
||||||
|
case .recipientTag:
|
||||||
|
guard length == tagLength else { return nil }
|
||||||
|
recipientTag = Data(value)
|
||||||
|
case .expiry:
|
||||||
|
guard length == 8 else { return nil }
|
||||||
|
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||||
|
case .ciphertext:
|
||||||
|
guard length > 0, length <= maxCiphertextBytes else { return nil }
|
||||||
|
ciphertext = Data(value)
|
||||||
|
case nil:
|
||||||
|
// Unknown TLV: skip for forward compatibility.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||||
|
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipient Tags
|
||||||
|
|
||||||
|
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
|
||||||
|
|
||||||
|
/// UTC day number used to rotate recipient tags.
|
||||||
|
public static func epochDay(for date: Date) -> UInt32 {
|
||||||
|
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rotating recipient hint for a given day. Computable only by parties
|
||||||
|
/// who already know the recipient's Noise static public key.
|
||||||
|
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
|
||||||
|
var message = tagContext
|
||||||
|
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
|
||||||
|
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
|
||||||
|
return Data(mac).prefix(tagLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags to test when checking whether an envelope is addressed to a peer.
|
||||||
|
/// Covers the adjacent days so envelopes sealed near midnight (or across
|
||||||
|
/// modest clock skew) still match while being carried.
|
||||||
|
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
|
||||||
|
let day = epochDay(for: date)
|
||||||
|
return [day == 0 ? 0 : day - 1, day, day + 1].map {
|
||||||
|
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,20 @@ import struct Foundation.Date
|
|||||||
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
case sent // Left our device
|
||||||
|
case carried // Sealed envelope handed to a courier; best-effort physical delivery
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
case read(by: String, at: Date) // Seen by recipient
|
case read(by: String, at: Date) // Seen by recipient
|
||||||
case failed(reason: String)
|
case failed(reason: String)
|
||||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||||
|
|
||||||
public var displayText: String {
|
public var displayText: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .sending:
|
case .sending:
|
||||||
return "Sending..."
|
return "Sending..."
|
||||||
case .sent:
|
case .sent:
|
||||||
return "Sent"
|
return "Sent"
|
||||||
|
case .carried:
|
||||||
|
return "Carried by a friend"
|
||||||
case .delivered(let nickname, _):
|
case .delivered(let nickname, _):
|
||||||
return "Delivered to \(nickname)"
|
return "Delivered to \(nickname)"
|
||||||
case .read(let nickname, _):
|
case .read(let nickname, _):
|
||||||
|
|||||||
@@ -12,8 +12,9 @@
|
|||||||
public enum MessageType: UInt8 {
|
public enum MessageType: UInt8 {
|
||||||
// Public messages (unencrypted)
|
// Public messages (unencrypted)
|
||||||
case announce = 0x01 // "I'm here" with nickname
|
case announce = 0x01 // "I'm here" with nickname
|
||||||
case message = 0x02 // Public chat message
|
case message = 0x02 // Public chat message
|
||||||
case leave = 0x03 // "I'm leaving"
|
case leave = 0x03 // "I'm leaving"
|
||||||
|
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||||
|
|
||||||
// Noise encryption
|
// Noise encryption
|
||||||
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
|
|||||||
case .announce: return "announce"
|
case .announce: return "announce"
|
||||||
case .message: return "message"
|
case .message: return "message"
|
||||||
case .leave: return "leave"
|
case .leave: return "leave"
|
||||||
|
case .courierEnvelope: return "courierEnvelope"
|
||||||
case .requestSync: return "requestSync"
|
case .requestSync: return "requestSync"
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
case .noiseHandshake: return "noiseHandshake"
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
case .noiseEncrypted: return "noiseEncrypted"
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ struct BinaryProtocolTests {
|
|||||||
// Verify recipient
|
// Verify recipient
|
||||||
#expect(decodedPacket.recipientID != nil)
|
#expect(decodedPacket.recipientID != nil)
|
||||||
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
let decodedRecipientID = decodedPacket.recipientID?.trimmingNullBytes()
|
||||||
// TODO: Check if this is intended that the decoding only gets the first 8
|
// Recipient IDs are a fixed 8-byte wire field: encode pads or truncates
|
||||||
|
// to BinaryProtocol.recipientIDSize, so only the first 8 bytes survive.
|
||||||
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
#expect(String(data: decodedRecipientID!, encoding: .utf8) == "abcdef01")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +295,7 @@ struct BinaryProtocolTests {
|
|||||||
@Test("Create a large, compressible payload above current threshold (2048B)")
|
@Test("Create a large, compressible payload above current threshold (2048B)")
|
||||||
func payloadCompression() throws {
|
func payloadCompression() throws {
|
||||||
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
let repeatedString = String(repeating: "This is a test message. ", count: 200)
|
||||||
let largePayload = repeatedString.data(using: .utf8)!
|
let largePayload = Data(repeatedString.utf8)
|
||||||
|
|
||||||
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
let packet = TestHelpers.createTestPacket(payload: largePayload)
|
||||||
|
|
||||||
@@ -314,7 +315,7 @@ struct BinaryProtocolTests {
|
|||||||
|
|
||||||
@Test("Small payloads should not be compressed")
|
@Test("Small payloads should not be compressed")
|
||||||
func smallPayloadNoCompression() throws {
|
func smallPayloadNoCompression() throws {
|
||||||
let smallPayload = "Hi".data(using: .utf8)!
|
let smallPayload = Data("Hi".utf8)
|
||||||
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
let packet = TestHelpers.createTestPacket(payload: smallPayload)
|
||||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode small packet")
|
||||||
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
let decodedPacket = try #require(BinaryProtocol.decode(encodedData), "Failed to decode small packet")
|
||||||
@@ -362,7 +363,7 @@ struct BinaryProtocolTests {
|
|||||||
var encodedSizes = Set<Int>()
|
var encodedSizes = Set<Int>()
|
||||||
|
|
||||||
for payload in payloads {
|
for payload in payloads {
|
||||||
let packet = TestHelpers.createTestPacket(payload: payload.data(using: .utf8)!)
|
let packet = TestHelpers.createTestPacket(payload: Data(payload.utf8))
|
||||||
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
let encodedData = try #require(BinaryProtocol.encode(packet), "Failed to encode packet")
|
||||||
|
|
||||||
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
// Verify padding creates standard block sizes up to configured limit (no 4096 bucket currently)
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//
|
||||||
|
// CourierEnvelopeTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import BitFoundation
|
||||||
|
|
||||||
|
struct CourierEnvelopeTests {
|
||||||
|
|
||||||
|
private func makeEnvelope(
|
||||||
|
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
|
||||||
|
expiry: UInt64 = 1_900_000_000_000,
|
||||||
|
ciphertext: Data = Data(repeating: 0x42, count: 128)
|
||||||
|
) -> CourierEnvelope {
|
||||||
|
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codec
|
||||||
|
|
||||||
|
@Test func roundTrip() throws {
|
||||||
|
let envelope = makeEnvelope()
|
||||||
|
let encoded = try #require(envelope.encode())
|
||||||
|
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||||
|
#expect(decoded == envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTripAtMaxCiphertextSize() throws {
|
||||||
|
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
|
||||||
|
let encoded = try #require(envelope.encode())
|
||||||
|
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||||
|
#expect(decoded == envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func encodeRejectsInvalidFields() {
|
||||||
|
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
|
||||||
|
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
|
||||||
|
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeRejectsMissingFields() throws {
|
||||||
|
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
|
||||||
|
let encoded = try #require(makeEnvelope().encode())
|
||||||
|
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
|
||||||
|
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeRejectsTruncatedValue() throws {
|
||||||
|
let encoded = try #require(makeEnvelope().encode())
|
||||||
|
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeSkipsUnknownTLVs() throws {
|
||||||
|
var encoded = try #require(makeEnvelope().encode())
|
||||||
|
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
|
||||||
|
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||||
|
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||||
|
#expect(decoded == makeEnvelope())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeOffsetSlice() throws {
|
||||||
|
// Decoder must handle slices with non-zero startIndex.
|
||||||
|
let encoded = try #require(makeEnvelope().encode())
|
||||||
|
let padded = Data([0xFF, 0xFF]) + encoded
|
||||||
|
let slice = padded.dropFirst(2)
|
||||||
|
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
|
||||||
|
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Expiry
|
||||||
|
|
||||||
|
@Test func expiryComparison() {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
|
||||||
|
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
|
||||||
|
#expect(makeEnvelope(expiry: 0).isExpired)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recipient Tags
|
||||||
|
|
||||||
|
@Test func tagIsDeterministicPerKeyAndDay() {
|
||||||
|
let key = Data(repeating: 0x11, count: 32)
|
||||||
|
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||||
|
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||||
|
#expect(tag1 == tag2)
|
||||||
|
#expect(tag1.count == CourierEnvelope.tagLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tagRotatesAcrossDaysAndKeys() {
|
||||||
|
let key = Data(repeating: 0x11, count: 32)
|
||||||
|
let otherKey = Data(repeating: 0x22, count: 32)
|
||||||
|
let day: UInt32 = 20_000
|
||||||
|
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||||
|
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
|
||||||
|
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||||
|
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func candidateTagsCoverAdjacentDays() {
|
||||||
|
let key = Data(repeating: 0x33, count: 32)
|
||||||
|
let date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
let day = CourierEnvelope.epochDay(for: date)
|
||||||
|
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
|
||||||
|
#expect(candidates.count == 3)
|
||||||
|
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
|
||||||
|
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
|
||||||
|
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func sealedYesterdayMatchesToday() {
|
||||||
|
// An envelope sealed late on day D must still match the recipient on day D+1.
|
||||||
|
let key = Data(repeating: 0x44, count: 32)
|
||||||
|
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
|
||||||
|
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
|
||||||
|
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
|
||||||
|
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,13 +54,13 @@ final class TestHelpers {
|
|||||||
type: UInt8 = 0x01,
|
type: UInt8 = 0x01,
|
||||||
senderID: PeerID = PeerID(str: UUID().uuidString),
|
senderID: PeerID = PeerID(str: UUID().uuidString),
|
||||||
recipientID: PeerID? = nil,
|
recipientID: PeerID? = nil,
|
||||||
payload: Data = "test payload".data(using: .utf8)!,
|
payload: Data = Data("test payload".utf8),
|
||||||
signature: Data? = nil,
|
signature: Data? = nil,
|
||||||
ttl: UInt8 = 3
|
ttl: UInt8 = 3
|
||||||
) -> BitchatPacket {
|
) -> BitchatPacket {
|
||||||
return BitchatPacket(
|
return BitchatPacket(
|
||||||
type: type,
|
type: type,
|
||||||
senderID: senderID.id.data(using: .utf8)!,
|
senderID: Data(senderID.id.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,
|
||||||
|
|||||||
@@ -11,16 +11,34 @@
|
|||||||
# never runner variance. Raise floors deliberately after intentional
|
# never runner variance. Raise floors deliberately after intentional
|
||||||
# improvements; never tune them to chase noise.
|
# improvements; never tune them to chase noise.
|
||||||
#
|
#
|
||||||
|
# Retry-on-noise: even generous floors can be dipped under by a saturated
|
||||||
|
# runner (observed: gcs.buildAndDecode at 85% of floor on a loaded GitHub
|
||||||
|
# macOS runner). When a benchmark lands below its floor, the gate re-runs the
|
||||||
|
# benchmark suite — appending to the same PERF log — and keeps each
|
||||||
|
# benchmark's BEST observed value across attempts. Runner noise clears on a
|
||||||
|
# retry; a real algorithmic regression stays below floor on every attempt and
|
||||||
|
# still fails. Floors themselves are never lowered by this mechanism.
|
||||||
|
#
|
||||||
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
|
# Usage: scripts/check-perf-floors.sh <test-output-file> [floors-file]
|
||||||
#
|
#
|
||||||
|
# Environment:
|
||||||
|
# BITCHAT_PERF_GATE_ATTEMPTS total measurement attempts (default 3)
|
||||||
|
# BITCHAT_PERF_REMEASURE_CMD command run to re-measure on a below-floor
|
||||||
|
# result (default: swift test --quiet
|
||||||
|
# --filter PerformanceBaselineTests). The
|
||||||
|
# command runs with BITCHAT_PERF_LOG pointed at
|
||||||
|
# the output file so new PERF lines append.
|
||||||
|
#
|
||||||
# Skips gracefully (exit 0) when:
|
# Skips gracefully (exit 0) when:
|
||||||
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
|
# - BITCHAT_SKIP_PERF_BASELINES=1 (perf tests were skipped), or
|
||||||
# - the output contains no PERF lines (e.g. package-only matrix entries).
|
# - the output contains no PERF lines (e.g. package-only matrix entries).
|
||||||
#
|
#
|
||||||
# Fails (exit 1) when:
|
# Fails when:
|
||||||
# - any benchmark reports throughput below its floor, or
|
# - any benchmark reports throughput below its floor on every attempt
|
||||||
# - PERF lines are present but a floored benchmark is missing
|
# (exit 1), or
|
||||||
# (a silently-dropped benchmark must be an explicit floors-file change).
|
# - PERF lines are present but a floored benchmark is missing — a
|
||||||
|
# silently-dropped benchmark must be an explicit floors-file change and
|
||||||
|
# is not retried (exit 3).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -31,6 +49,8 @@ fi
|
|||||||
|
|
||||||
OUTPUT_FILE="$1"
|
OUTPUT_FILE="$1"
|
||||||
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
|
FLOORS_FILE="${2:-$(cd "$(dirname "$0")/.." && pwd)/bitchatTests/Performance/perf-floors.json}"
|
||||||
|
MAX_ATTEMPTS="${BITCHAT_PERF_GATE_ATTEMPTS:-3}"
|
||||||
|
REMEASURE_CMD="${BITCHAT_PERF_REMEASURE_CMD:-swift test --quiet --filter PerformanceBaselineTests}"
|
||||||
|
|
||||||
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
|
if [[ "${BITCHAT_SKIP_PERF_BASELINES:-}" == "1" ]]; then
|
||||||
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
|
echo "perf-floors: BITCHAT_SKIP_PERF_BASELINES=1 — skipping gate."
|
||||||
@@ -52,7 +72,17 @@ if ! grep -q 'PERF\[' "$OUTPUT_FILE"; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
# Absolute path so re-measurement appends to the same file regardless of the
|
||||||
|
# working directory the test process runs in.
|
||||||
|
case "$OUTPUT_FILE" in
|
||||||
|
/*) ;;
|
||||||
|
*) OUTPUT_FILE="$(pwd)/$OUTPUT_FILE" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Exit codes: 0 = all floors met, 1 = below floor (retryable — noise vs
|
||||||
|
# regression undecided), 3 = floored benchmark missing (not retryable).
|
||||||
|
check_floors() {
|
||||||
|
OUTPUT_FILE="$OUTPUT_FILE" FLOORS_FILE="$FLOORS_FILE" python3 - <<'PYEOF'
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -72,15 +102,20 @@ with open(output_file, errors="replace") as f:
|
|||||||
for line in f:
|
for line in f:
|
||||||
m = pattern.search(line)
|
m = pattern.search(line)
|
||||||
if m:
|
if m:
|
||||||
# Keep the last reported value if a benchmark prints twice.
|
# Keep the BEST reported value: measurement retries append to the
|
||||||
measured[m.group(1)] = (float(m.group(2)), m.group(3))
|
# same log, and a healthy benchmark only needs to clear its floor
|
||||||
|
# once — a real regression never does.
|
||||||
|
name, value, unit = m.group(1), float(m.group(2)), m.group(3)
|
||||||
|
if name not in measured or value > measured[name][0]:
|
||||||
|
measured[name] = (value, unit)
|
||||||
|
|
||||||
failures = []
|
below_floor = []
|
||||||
|
missing = []
|
||||||
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
|
print(f"perf-floors: checking {len(measured)} benchmark(s) against {len(floors)} floor(s)")
|
||||||
for name in sorted(set(floors) | set(measured)):
|
for name in sorted(set(floors) | set(measured)):
|
||||||
floor = floors.get(name)
|
floor = floors.get(name)
|
||||||
if name not in measured:
|
if name not in measured:
|
||||||
failures.append(
|
missing.append(
|
||||||
f" MISSING {name}: floored benchmark reported no PERF line "
|
f" MISSING {name}: floored benchmark reported no PERF line "
|
||||||
f"(removed/renamed? update perf-floors.json in the same change)")
|
f"(removed/renamed? update perf-floors.json in the same change)")
|
||||||
continue
|
continue
|
||||||
@@ -92,13 +127,18 @@ for name in sorted(set(floors) | set(measured)):
|
|||||||
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
|
line = f" {status:8} {name}: {value:.0f} {unit}/sec (floor {floor})"
|
||||||
print(line)
|
print(line)
|
||||||
if value < floor:
|
if value < floor:
|
||||||
failures.append(
|
below_floor.append(
|
||||||
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
|
f" BELOW {name}: {value:.0f} {unit}/sec is under floor {floor} "
|
||||||
f"({value / floor * 100:.0f}% of floor)")
|
f"({value / floor * 100:.0f}% of floor)")
|
||||||
|
|
||||||
if failures:
|
if missing:
|
||||||
print("\nperf-floors: FAILED — order-of-magnitude-class regression suspected:")
|
print("\nperf-floors: FAILED — floored benchmark(s) missing from the output:")
|
||||||
print("\n".join(failures))
|
print("\n".join(missing + below_floor))
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
if below_floor:
|
||||||
|
print("\nperf-floors: below floor — order-of-magnitude-class regression suspected:")
|
||||||
|
print("\n".join(below_floor))
|
||||||
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
|
print("\nFloors are ~25% of healthy local throughput; falling below one means an")
|
||||||
print("algorithmic regression, not runner noise. If the change is intentional,")
|
print("algorithmic regression, not runner noise. If the change is intentional,")
|
||||||
print("update bitchatTests/Performance/perf-floors.json deliberately.")
|
print("update bitchatTests/Performance/perf-floors.json deliberately.")
|
||||||
@@ -106,3 +146,36 @@ if failures:
|
|||||||
|
|
||||||
print("perf-floors: all benchmarks at or above their floors.")
|
print("perf-floors: all benchmarks at or above their floors.")
|
||||||
PYEOF
|
PYEOF
|
||||||
|
}
|
||||||
|
|
||||||
|
attempt=1
|
||||||
|
while true; do
|
||||||
|
gate_status=0
|
||||||
|
check_floors || gate_status=$?
|
||||||
|
|
||||||
|
case "$gate_status" in
|
||||||
|
0)
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
# Below floor: retry to separate runner noise from regression.
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
# Missing benchmark or parse/setup error: re-measuring can't help.
|
||||||
|
exit "$gate_status"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if (( attempt >= MAX_ATTEMPTS )); then
|
||||||
|
echo "perf-floors: still below floor after $attempt measurement attempt(s) — treating as a real regression." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
echo "perf-floors: re-measuring (attempt $attempt of $MAX_ATTEMPTS) to separate runner noise from a real regression."
|
||||||
|
# Word splitting of REMEASURE_CMD is deliberate: it is a command line.
|
||||||
|
if ! BITCHAT_PERF_LOG="$OUTPUT_FILE" $REMEASURE_CMD; then
|
||||||
|
echo "perf-floors: re-measurement command failed: $REMEASURE_CMD" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
// Standalone harness: does Apple's URLSession honor connectionProxyDictionary
|
|
||||||
// SOCKS settings for a plain HTTPS GET and for URLSessionWebSocketTask?
|
|
||||||
//
|
|
||||||
// Build: swiftc -O proxy_probe.swift -o proxy_probe
|
|
||||||
// Usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]
|
|
||||||
//
|
|
||||||
// Prints a single RESULT line: RESULT <mode> <keyStyle> <outcome> <detail>
|
|
||||||
// The caller correlates this with the SOCKS proxy's connection log to decide
|
|
||||||
// whether the request was proxied.
|
|
||||||
#if canImport(CFNetwork)
|
|
||||||
import CFNetwork
|
|
||||||
#endif
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
let args = CommandLine.arguments
|
|
||||||
guard args.count >= 4 else {
|
|
||||||
FileHandle.standardError.write(Data("usage: proxy_probe <http|ws> <cf|raw> <proxyPort> [targetURL]\n".utf8))
|
|
||||||
exit(2)
|
|
||||||
}
|
|
||||||
let mode = args[1]
|
|
||||||
let keyStyle = args[2]
|
|
||||||
let proxyPort = Int(args[3]) ?? 19999
|
|
||||||
let host = "127.0.0.1"
|
|
||||||
|
|
||||||
func makeProxyDict() -> [AnyHashable: Any] {
|
|
||||||
switch keyStyle {
|
|
||||||
#if os(macOS)
|
|
||||||
case "cf":
|
|
||||||
// The exact constants the app uses on macOS.
|
|
||||||
return [
|
|
||||||
kCFNetworkProxiesSOCKSEnable as String: 1,
|
|
||||||
kCFNetworkProxiesSOCKSProxy as String: host,
|
|
||||||
kCFNetworkProxiesSOCKSPort as String: proxyPort
|
|
||||||
]
|
|
||||||
#endif
|
|
||||||
default:
|
|
||||||
// The exact raw string keys the app uses on iOS.
|
|
||||||
return [
|
|
||||||
"SOCKSEnable": 1,
|
|
||||||
"SOCKSProxy": host,
|
|
||||||
"SOCKSPort": proxyPort
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
|
||||||
cfg.waitsForConnectivity = false
|
|
||||||
cfg.timeoutIntervalForRequest = 20
|
|
||||||
cfg.connectionProxyDictionary = makeProxyDict()
|
|
||||||
let session = URLSession(configuration: cfg)
|
|
||||||
|
|
||||||
func emit(_ outcome: String, _ detail: String) {
|
|
||||||
print("RESULT \(mode) \(keyStyle) \(outcome) \(detail)")
|
|
||||||
exit(outcome == "ERROR" ? 1 : 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
let sem = DispatchSemaphore(value: 0)
|
|
||||||
|
|
||||||
if mode == "http" {
|
|
||||||
let target = URL(string: args.count >= 5 ? args[4] : "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
|
||||||
let task = session.dataTask(with: target) { data, resp, err in
|
|
||||||
if let err = err {
|
|
||||||
emit("ERROR", "\(err.localizedDescription)")
|
|
||||||
} else if let http = resp as? HTTPURLResponse {
|
|
||||||
emit("OK", "status=\(http.statusCode) bytes=\(data?.count ?? 0)")
|
|
||||||
} else {
|
|
||||||
emit("OK", "bytes=\(data?.count ?? 0)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
task.resume()
|
|
||||||
} else {
|
|
||||||
// WebSocket
|
|
||||||
let target = URL(string: args.count >= 5 ? args[4] : "wss://relay.damus.io")!
|
|
||||||
let ws = session.webSocketTask(with: target)
|
|
||||||
ws.resume()
|
|
||||||
// A successful ping proves the TLS+WS handshake completed end-to-end.
|
|
||||||
ws.sendPing { err in
|
|
||||||
if let err = err {
|
|
||||||
emit("ERROR", "\(err.localizedDescription)")
|
|
||||||
} else {
|
|
||||||
emit("OK", "ws-ping-ok")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global watchdog so we never hang.
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: .now() + 25) {
|
|
||||||
emit("ERROR", "timeout")
|
|
||||||
}
|
|
||||||
sem.wait()
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Orchestrates the Tor-egress proxy-honoring verification on macOS.
|
|
||||||
#
|
|
||||||
# For each (request-type x key-style) it runs two experiments:
|
|
||||||
# A) proxy UP — did a connection arrive at the SOCKS proxy? (log grows)
|
|
||||||
# B) proxy DOWN — pointed at a dead port; does the request still SUCCEED?
|
|
||||||
# If it succeeds with no proxy, egress went DIRECT (proxy ignored).
|
|
||||||
# If it fails, the proxy setting is being enforced (fail-closed).
|
|
||||||
#
|
|
||||||
# Discriminator: PROXIED = connection observed at proxy AND fails when proxy down
|
|
||||||
# DIRECT = no connection at proxy OR succeeds when proxy down
|
|
||||||
set -u
|
|
||||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
PORT=19999
|
|
||||||
DEADPORT=19998 # nothing listens here
|
|
||||||
LOG="$(mktemp -t sockslog)"
|
|
||||||
BIN="$(mktemp -t proxyprobe)"
|
|
||||||
|
|
||||||
echo "== building swift probe =="
|
|
||||||
swiftc -O "$DIR/proxy_probe.swift" -o "$BIN" || { echo "swiftc failed"; exit 1; }
|
|
||||||
|
|
||||||
echo "== starting SOCKS proxy on $PORT =="
|
|
||||||
: > "$LOG"
|
|
||||||
python3 "$DIR/socks5_probe_proxy.py" "$PORT" "$LOG" >/tmp/socksproxy.out 2>&1 &
|
|
||||||
PROXY_PID=$!
|
|
||||||
trap 'kill $PROXY_PID 2>/dev/null' EXIT
|
|
||||||
# wait for READY
|
|
||||||
for _ in $(seq 1 50); do
|
|
||||||
grep -q READY /tmp/socksproxy.out 2>/dev/null && break
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
|
|
||||||
run_case() {
|
|
||||||
local mode="$1" key="$2"
|
|
||||||
# Experiment A: proxy up, watch log
|
|
||||||
local before after target
|
|
||||||
before=$(wc -l < "$LOG" | tr -d ' ')
|
|
||||||
local outA
|
|
||||||
outA=$("$BIN" "$mode" "$key" "$PORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
|
||||||
sleep 0.3
|
|
||||||
after=$(wc -l < "$LOG" | tr -d ' ')
|
|
||||||
local proxied="NO"
|
|
||||||
if [ "$after" -gt "$before" ]; then proxied="YES"; fi
|
|
||||||
local newlines
|
|
||||||
newlines=$(tail -n +"$((before+1))" "$LOG" | tr '\t' ' ' | tr '\n' '|')
|
|
||||||
|
|
||||||
# Experiment B: proxy down (dead port), same request
|
|
||||||
local outB
|
|
||||||
outB=$("$BIN" "$mode" "$key" "$DEADPORT" 2>/dev/null | grep '^RESULT' || echo "RESULT $mode $key ERROR no-output")
|
|
||||||
|
|
||||||
echo "----------------------------------------"
|
|
||||||
echo "CASE mode=$mode key=$key"
|
|
||||||
echo " A(proxy up): $outA | connection_at_proxy=$proxied [$newlines]"
|
|
||||||
echo " B(proxy down): $outB"
|
|
||||||
# verdict
|
|
||||||
local a_ok b_ok
|
|
||||||
a_ok=$(echo "$outA" | awk '{print $4}')
|
|
||||||
b_ok=$(echo "$outB" | awk '{print $4}')
|
|
||||||
local verdict="UNKNOWN"
|
|
||||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "ERROR" ]; then verdict="PROXIED (enforced)"; fi
|
|
||||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "OK" ]; then verdict="DIRECT (proxy ignored)"; fi
|
|
||||||
if [ "$proxied" = "YES" ] && [ "$b_ok" = "OK" ]; then verdict="AMBIGUOUS (uses proxy if up, but egresses direct if down)"; fi
|
|
||||||
if [ "$proxied" = "NO" ] && [ "$b_ok" = "ERROR" ]; then verdict="BLOCKED both (network/target issue?)"; fi
|
|
||||||
echo " VERDICT: $verdict"
|
|
||||||
}
|
|
||||||
|
|
||||||
for mode in http ws; do
|
|
||||||
for key in cf raw; do
|
|
||||||
run_case "$mode" "$key"
|
|
||||||
done
|
|
||||||
done
|
|
||||||
echo "========================================"
|
|
||||||
echo "raw proxy log:"; cat "$LOG" | tr '\t' ' '
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Minimal threaded SOCKS5 CONNECT proxy used to verify whether Apple's
|
|
||||||
URLSession actually honors `connectionProxyDictionary` SOCKS settings for
|
|
||||||
different request types (plain HTTPS vs URLSessionWebSocketTask).
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Speaks enough SOCKS5 (no-auth) to complete a CONNECT and then relays
|
|
||||||
bytes bidirectionally to the real destination.
|
|
||||||
- Every accepted CONNECT is appended to a log file as one line:
|
|
||||||
<iso8601>\tCONNECT\t<host>:<port>
|
|
||||||
- Any raw connection that is NOT valid SOCKS5 is logged as:
|
|
||||||
<iso8601>\tNON_SOCKS\t<first-bytes-hex>
|
|
||||||
(this catches the feared case where URLSession sends a raw TLS/HTTP
|
|
||||||
ClientHello straight at the proxy port instead of a SOCKS greeting).
|
|
||||||
|
|
||||||
If a request egresses DIRECTLY (proxy ignored), nothing is logged at all.
|
|
||||||
|
|
||||||
Usage: socks5_probe_proxy.py <listen_port> <log_file>
|
|
||||||
"""
|
|
||||||
import selectors
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
LOG_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def log(logfile, kind, detail):
|
|
||||||
line = f"{datetime.now(timezone.utc).isoformat()}\t{kind}\t{detail}\n"
|
|
||||||
with LOG_LOCK:
|
|
||||||
with open(logfile, "a") as f:
|
|
||||||
f.write(line)
|
|
||||||
sys.stderr.write("[proxy] " + line)
|
|
||||||
sys.stderr.flush()
|
|
||||||
|
|
||||||
|
|
||||||
def recv_exact(sock, n):
|
|
||||||
buf = b""
|
|
||||||
while len(buf) < n:
|
|
||||||
chunk = sock.recv(n - len(buf))
|
|
||||||
if not chunk:
|
|
||||||
return None
|
|
||||||
buf += chunk
|
|
||||||
return buf
|
|
||||||
|
|
||||||
|
|
||||||
def handle(client, logfile):
|
|
||||||
client.settimeout(15)
|
|
||||||
try:
|
|
||||||
# SOCKS5 greeting: VER=0x05, NMETHODS, METHODS...
|
|
||||||
head = recv_exact(client, 2)
|
|
||||||
if not head:
|
|
||||||
return
|
|
||||||
if head[0] != 0x05:
|
|
||||||
# Not SOCKS5 at all — this is the smoking gun for a direct egress
|
|
||||||
# that mistakenly hit the proxy port. Log the first bytes.
|
|
||||||
rest = b""
|
|
||||||
try:
|
|
||||||
client.setblocking(False)
|
|
||||||
rest = client.recv(64)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
log(logfile, "NON_SOCKS", (head + rest).hex())
|
|
||||||
return
|
|
||||||
nmethods = head[1]
|
|
||||||
if nmethods:
|
|
||||||
recv_exact(client, nmethods)
|
|
||||||
# Reply: no authentication required
|
|
||||||
client.sendall(b"\x05\x00")
|
|
||||||
|
|
||||||
# Request: VER, CMD, RSV, ATYP, ADDR, PORT
|
|
||||||
req = recv_exact(client, 4)
|
|
||||||
if not req or req[1] != 0x01: # only CONNECT
|
|
||||||
client.sendall(b"\x05\x07\x00\x01\x00\x00\x00\x00\x00\x00")
|
|
||||||
return
|
|
||||||
atyp = req[3]
|
|
||||||
if atyp == 0x01: # IPv4
|
|
||||||
addr = socket.inet_ntoa(recv_exact(client, 4))
|
|
||||||
elif atyp == 0x03: # domain
|
|
||||||
ln = recv_exact(client, 1)[0]
|
|
||||||
addr = recv_exact(client, ln).decode("ascii", errors="replace")
|
|
||||||
elif atyp == 0x04: # IPv6
|
|
||||||
addr = socket.inet_ntop(socket.AF_INET6, recv_exact(client, 16))
|
|
||||||
else:
|
|
||||||
client.sendall(b"\x05\x08\x00\x01\x00\x00\x00\x00\x00\x00")
|
|
||||||
return
|
|
||||||
port = int.from_bytes(recv_exact(client, 2), "big")
|
|
||||||
|
|
||||||
log(logfile, "CONNECT", f"{addr}:{port}")
|
|
||||||
|
|
||||||
# Connect to the real destination and reply success.
|
|
||||||
try:
|
|
||||||
remote = socket.create_connection((addr, port), timeout=15)
|
|
||||||
except Exception as e:
|
|
||||||
log(logfile, "CONNECT_FAIL", f"{addr}:{port} {e}")
|
|
||||||
client.sendall(b"\x05\x01\x00\x01\x00\x00\x00\x00\x00\x00")
|
|
||||||
return
|
|
||||||
client.sendall(b"\x05\x00\x00\x01\x00\x00\x00\x00\x00\x00")
|
|
||||||
|
|
||||||
relay(client, remote)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
client.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def relay(a, b):
|
|
||||||
a.setblocking(False)
|
|
||||||
b.setblocking(False)
|
|
||||||
sel = selectors.DefaultSelector()
|
|
||||||
sel.register(a, selectors.EVENT_READ, b)
|
|
||||||
sel.register(b, selectors.EVENT_READ, a)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
events = sel.select(timeout=30)
|
|
||||||
if not events:
|
|
||||||
break
|
|
||||||
for key, _ in events:
|
|
||||||
src = key.fileobj
|
|
||||||
dst = key.data
|
|
||||||
try:
|
|
||||||
data = src.recv(65536)
|
|
||||||
except (BlockingIOError, InterruptedError):
|
|
||||||
continue
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
if not data:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
dst.sendall(data)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
finally:
|
|
||||||
sel.close()
|
|
||||||
for s in (a, b):
|
|
||||||
try:
|
|
||||||
s.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print("usage: socks5_probe_proxy.py <port> <logfile>", file=sys.stderr)
|
|
||||||
sys.exit(2)
|
|
||||||
port = int(sys.argv[1])
|
|
||||||
logfile = sys.argv[2]
|
|
||||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
srv.bind(("127.0.0.1", port))
|
|
||||||
srv.listen(64)
|
|
||||||
sys.stderr.write(f"[proxy] listening on 127.0.0.1:{port}, log={logfile}\n")
|
|
||||||
sys.stderr.flush()
|
|
||||||
print("READY", flush=True)
|
|
||||||
while True:
|
|
||||||
client, _ = srv.accept()
|
|
||||||
threading.Thread(target=handle, args=(client, logfile), daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user