Compare commits

..
14 Commits
Author SHA1 Message Date
islam 94e19dc039 Remove convenience init with senderPeerID 2025-09-18 02:10:48 +01:00
islam 438357486f Introduce Peer for safer handling of peerID 2025-09-18 02:10:48 +01:00
islam 4a382716bb Break down / flatten resubscribeCurrentGeohash 2025-09-18 02:09:05 +01:00
islam 1563b06d73 Extract subscribeNostrEvent into a function 2025-09-18 02:09:05 +01:00
islam abb7489e2f Break down / flatten beginGeohashSampling 2025-09-18 02:09:05 +01:00
islam 2bc875dee5 updateChannelActivityTimeThenSend function 2025-09-18 02:09:05 +01:00
islam 588cef72ce Extract processNostrMessage into a function 2025-09-18 02:09:05 +01:00
RubensandGitHub 221819b591 fix: crash on shared extension (#621)
* fix: crash on shared extension

* fix: global(qos: .default) to global()

* fix: removed weak self
2025-09-17 08:03:07 -07:00
RubensandGitHub 4a21ab0531 chore: debug icon (#634)
* chore: add debug icon to make it easier to identify debug builds on developers' devices

* chore: add new AppIcon assets with 1024x1024
2025-09-17 08:00:06 -07:00
jack 50ae8da5f9 Bump marketing version to 1.4.2 2025-09-16 08:16:21 -07:00
54bb812469 Gate relays on mutual favorites and add Tor toggle (#631)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-09-16 08:12:51 -07:00
IslamandGitHub 482fca81ef Single source of truth for Marketing Version (#627) 2025-09-16 06:44:30 -07:00
IslamandGitHub b2e7d2d26e Set macOS App Category as Social Media as well (#628) 2025-09-16 06:43:23 -07:00
IslamandGitHub 6a2832d22b Prevent Github Languages stats skewing (#630) 2025-09-16 06:42:36 -07:00
27 changed files with 1028 additions and 557 deletions
+20
View File
@@ -0,0 +1,20 @@
# Prevent Github Languages stats skewing:
# Binaries and assets
**/*.xcframework/** linguist-vendored
**/*.xcassets/** linguist-vendored
# Generated files
**/*.pbxproj linguist-generated
**/*.storyboard linguist-generated
Package.resolved linguist-generated
# Downloaded CSVs
relays/online_relays_gps.csv linguist-vendored
# Docs
**/*.md linguist-documentation
# Configs
Configs/*.xcconfig linguist-documentation
**/*.plist linguist-documentation
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.4.1
MARKETING_VERSION = 1.4.2
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+10 -8
View File
@@ -481,7 +481,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -498,7 +498,7 @@
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
@@ -514,7 +514,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -574,7 +574,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -604,12 +604,13 @@
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -683,7 +684,7 @@
baseConfigurationReferenceAnchor = A6E367C92E76469E0032EA8A /* Configs */;
baseConfigurationReferenceRelativePath = Debug.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIconDebug;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
@@ -695,12 +696,13 @@
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.social-networking";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -795,7 +797,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.4.1;
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -0,0 +1,38 @@
{
"images" : [
{
"filename" : "image-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "image-1024 1.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"filename" : "image-1024 2.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+1 -1
View File
@@ -3,4 +3,4 @@
"author" : "xcode",
"version" : 1
}
}
}
+23 -10
View File
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let senderPeer: Peer?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
@@ -39,10 +39,23 @@ final class BitchatMessage: Codable {
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
case isPrivate, recipientNickname, mentions, deliveryStatus
case senderPeer = "senderPeerID" // backwards compatibility
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
init(
id: String? = nil,
sender: String,
content: String,
timestamp: Date,
isRelay: Bool,
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeer: Peer? = nil,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
@@ -51,7 +64,7 @@ final class BitchatMessage: Codable {
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.senderPeer = senderPeer
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
@@ -69,7 +82,7 @@ extension BitchatMessage: Equatable {
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.senderPeer == rhs.senderPeer &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
@@ -101,7 +114,7 @@ extension BitchatMessage {
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if senderPeer != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
@@ -151,7 +164,7 @@ extension BitchatMessage {
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
if let peerData = senderPeer?.data {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
@@ -264,11 +277,11 @@ extension BitchatMessage {
}
}
var senderPeerID: String?
var senderPeer: Peer?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
senderPeer = Peer(data: dataCopy[offset..<offset+length])
offset += length
}
}
@@ -302,7 +315,7 @@ extension BitchatMessage {
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
senderPeer: senderPeer,
mentions: mentions
)
}
+75
View File
@@ -0,0 +1,75 @@
//
// Peer.swift
// BitLogger
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
struct Peer: Equatable, Hashable {
let id: String
}
extension Peer {
var data: Data? {
id.data(using: .utf8)
}
var isNostr: Bool {
id.hasPrefix("nostr")
}
var isNostrColon: Bool {
id.hasPrefix("nostr:")
}
}
// MARK: - ExpressibleByStringLiteral
extension Peer: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.init(str: value)
}
}
// MARK: - ExpressibleByStringInterpolation
extension Peer: ExpressibleByStringInterpolation {
init(extendedGraphemeClusterLiteral value: String) {
self.init(str: value)
}
}
// MARK: - Codable
extension Peer: Codable {
init(from decoder: any Decoder) throws {
id = try decoder.singleValueContainer().decode(String.self)
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(id)
}
}
// MARK: - Convenience Inits
extension Peer {
init(str: String) {
id = str.lowercased()
}
init(str: String.SubSequence) {
self.init(str: String(str))
}
init?(data: Data) {
guard let str = String(data: data, encoding: .utf8) else {
return nil
}
self.init(str: str)
}
}
+121 -45
View File
@@ -35,10 +35,12 @@ final class NostrRelayManager: ObservableObject {
"wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
private var allowDefaultRelays: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
@@ -65,6 +67,8 @@ final class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -78,29 +82,45 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0
init() {
// Initialize with default relays
self.relays = Self.defaultRelays.map { Relay(url: $0) }
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
allowDefaultRelays = hasMutual
if hasMutual {
self.relays = Self.defaultRelays.map { Relay(url: $0) }
}
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
}
.store(in: &cancellables)
}
/// Connect to all configured relays
func connect() {
// Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return }
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
guard networkService.activationAllowed else { return }
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
}
@@ -120,8 +140,10 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
// Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return }
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
guard networkService.activationAllowed else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in
guard let self = self else { return }
@@ -130,22 +152,21 @@ final class NostrRelayManager: ObservableObject {
}
return
}
let existing = Set(relays.map { $0.url })
for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url))
}
if connections[url] == nil {
connectToRelay(url)
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
for url in targets where connections[url] == nil {
connectToRelay(url)
}
}
/// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return }
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
guard networkService.activationAllowed else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in
guard let self = self else { return }
@@ -154,7 +175,9 @@ final class NostrRelayManager: ObservableObject {
}
return
}
let targetRelays = relayUrls ?? Self.defaultRelays
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
guard !targetRelays.isEmpty else { return }
ensureConnections(to: targetRelays)
// Attempt immediate send to relays with active connections; queue the rest
@@ -220,7 +243,7 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil
) {
// Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return }
guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
if messageHandlers[id] != nil {
@@ -229,7 +252,7 @@ final class NostrRelayManager: ObservableObject {
}
}
subscribeCoalesce[id] = now
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in
guard let self = self else { return }
@@ -257,32 +280,37 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays
let urls = baseUrls.filter { !isPermanentlyFailed($0) }
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
}
for url in urls {
for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
if urls.isEmpty {
onEOSE()
} else {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
eoseTrackers[id] = tracker
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush
@@ -297,6 +325,54 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to encode subscription request: \(error)", category: .session)
}
}
private func updateDefaultRelayPolicy(hasMutual: Bool) {
guard hasMutual != allowDefaultRelays else { return }
allowDefaultRelays = hasMutual
if hasMutual {
var existing = Set(relays.map { $0.url })
for url in Self.defaultRelays where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays)
}
} else {
for url in Self.defaultRelays {
if let connection = connections[url] {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url)
}
messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() {
var item = messageQueue[index]
item.pendingRelays.subtract(Self.defaultRelaySet)
if item.pendingRelays.isEmpty {
messageQueue.remove(at: index)
} else {
messageQueue[index] = item
}
}
messageQueueLock.unlock()
relays.removeAll { Self.defaultRelaySet.contains($0.url) }
updateConnectionStatus()
}
}
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
}
}
return result
}
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
@@ -326,14 +402,14 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) {
// Global network policy gate
if !TorManager.shared.isAutoStartAllowed() { return }
guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return
}
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return
}
@@ -348,7 +424,7 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is.
if TorManager.shared.torEnforced && !TorManager.shared.isReady {
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
Task.detached { [weak self] in
guard let self = self else { return }
let ready = await TorManager.shared.awaitReady()
@@ -534,7 +610,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !TorManager.shared.isAutoStartAllowed() {
if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
+59 -16
View File
@@ -10,9 +10,12 @@ final class NetworkActivationService: ObservableObject {
static let shared = NetworkActivationService()
@Published private(set) var activationAllowed: Bool = false
@Published private(set) var userTorEnabled: Bool = true
private var cancellables = Set<AnyCancellable>()
private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false
private init() {}
@@ -20,9 +23,23 @@ final class NetworkActivationService: ObservableObject {
guard !started else { return }
started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored
} else {
userTorEnabled = true
}
// Initial compute
activationAllowed = Self.computeAllowed()
TorManager.shared.setAutoStartAllowed(activationAllowed)
let allowed = basePolicyAllowed()
activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired)
if allowed {
NostrRelayManager.shared.connect()
} else {
NostrRelayManager.shared.disconnect()
}
// React to location permission changes
LocationChannelManager.shared.$permissionState
@@ -41,30 +58,56 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables)
}
func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return }
userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post(
name: .TorUserPreferenceChanged,
object: nil,
userInfo: ["enabled": enabled]
)
reevaluate()
}
private func reevaluate() {
let allowed = Self.computeAllowed()
if allowed != activationAllowed {
let allowed = basePolicyAllowed()
let torDesired = allowed && userTorEnabled
let statusChanged = allowed != activationAllowed
let torChanged = torDesired != torAutoStartDesired
if statusChanged {
SecureLogger.info("NetworkActivationService: activationAllowed -> \(allowed)", category: .session)
activationAllowed = allowed
TorManager.shared.setAutoStartAllowed(allowed)
if allowed {
// Kick Tor + relays if we're now permitted
TorManager.shared.startIfNeeded()
// If app is in foreground, begin relay connections
if TorManager.shared.isForeground() {
NostrRelayManager.shared.connect()
}
} else {
// Transitioned to disallowed: disconnect relays and shut down Tor
}
if statusChanged || torChanged {
torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired)
}
if allowed {
if torChanged {
// Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect()
TorManager.shared.goDormantOnBackground()
}
NostrRelayManager.shared.connect()
} else if statusChanged {
NostrRelayManager.shared.disconnect()
}
}
private static func computeAllowed() -> Bool {
private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual
}
private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired {
TorManager.shared.startIfNeeded()
} else {
TorManager.shared.shutdownCompletely()
}
}
}
+4 -4
View File
@@ -73,7 +73,7 @@ final class PrivateChatManager: ObservableObject {
originalSender: nil,
isPrivate: true,
recipientNickname: peerNickname,
senderPeerID: meshService.myPeerID,
senderPeer: Peer(str: meshService.myPeerID),
mentions: nil,
deliveryStatus: .sending
)
@@ -94,7 +94,7 @@ final class PrivateChatManager: ObservableObject {
/// Handle incoming private message
func handleIncomingMessage(_ message: BitchatMessage) {
guard let senderPeerID = message.senderPeerID else { return }
guard let senderPeerID = message.senderPeer?.id else { return }
// Initialize chat if needed
if privateChats[senderPeerID] == nil {
@@ -161,7 +161,7 @@ final class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
@@ -214,7 +214,7 @@ final class PrivateChatManager: ObservableObject {
private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
let senderPeerID = message.senderPeer?.id else {
return
}
+18
View File
@@ -539,6 +539,24 @@ final class TorManager: ObservableObject {
}
}
func shutdownCompletely() {
Task.detached { [weak self] in
guard let self = self else { return }
_ = tor_host_shutdown()
await MainActor.run {
self.isDormant = false
self.isReady = false
self.socksReady = false
self.bootstrapProgress = 0
self.bootstrapSummary = ""
self.isStarting = false
self.didStart = false
self.restarting = false
self.controlMonitorStarted = false
}
}
}
private func restartTor() async {
await MainActor.run {
// Announce restart so UI can notify the user
@@ -4,4 +4,5 @@ extension Notification.Name {
static let TorDidBecomeReady = Notification.Name("TorDidBecomeReady")
static let TorWillRestart = Notification.Name("TorWillRestart")
static let TorWillStart = Notification.Name("TorWillStart")
static let TorUserPreferenceChanged = Notification.Name("TorUserPreferenceChanged")
}
+19 -22
View File
@@ -4,43 +4,34 @@ import CFNetwork
#endif
/// Provides a shared URLSession that routes traffic via Tor's SOCKS5 proxy
/// when Tor is enforced/ready. Falls back to a default session only when
/// compiled with the `BITCHAT_DEV_ALLOW_CLEARNET` flag.
/// when Tor is enforced/ready. Allows swapping between proxied and direct
/// sessions so UI can toggle Tor usage at runtime.
final class TorURLSession {
static let shared = TorURLSession()
// Default (no proxy) session for local development when dev bypass is enabled.
private var defaultSession: URLSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
// Default (no proxy) session for direct Nostr access when Tor is disabled.
private var defaultSession: URLSession = TorURLSession.makeDefaultSession()
// Proxied (SOCKS5) session that routes through Tor.
private var torSession: URLSession = TorURLSession.makeTorSession()
private var useTorProxy: Bool = true
var session: URLSession {
#if BITCHAT_DEV_ALLOW_CLEARNET
// Dev bypass: use direct session. Call sites may still await Tor if desired.
return defaultSession
#else
// Production: always use the Tor-proxied session. Call sites ensure readiness.
return torSession
#endif
useTorProxy ? torSession : defaultSession
}
// Recreate sessions so new clients bind to the fresh SOCKS/control ports after a Tor restart.
func rebuild() {
#if BITCHAT_DEV_ALLOW_CLEARNET
defaultSession = {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}()
#endif
defaultSession = TorURLSession.makeDefaultSession()
torSession = TorURLSession.makeTorSession()
}
func setProxyMode(useTor: Bool) {
guard useTorProxy != useTor else { return }
useTorProxy = useTor
rebuild()
}
private static func makeTorSession() -> URLSession {
let cfg = URLSessionConfiguration.ephemeral
cfg.waitsForConnectivity = true
@@ -63,4 +54,10 @@ final class TorURLSession {
#endif
return URLSession(configuration: cfg)
}
private static func makeDefaultSession() -> URLSession {
let cfg = URLSessionConfiguration.default
cfg.waitsForConnectivity = true
return URLSession(configuration: cfg)
}
}
+1 -1
View File
@@ -188,7 +188,7 @@ enum TransportConfig {
static let uiWindowStepCount: Int = 200
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -459,7 +459,7 @@ struct ContentView: View {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeer?.id == peerID && $0.sender != "system" })?.sender
}
}
if viewModel.isSelfSender(peerID: selectedMessageSenderID, displayName: selectedMessageSender) {
+59 -1
View File
@@ -9,6 +9,7 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@ObservedObject private var network = NetworkActivationService.shared
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = ""
@@ -266,6 +267,7 @@ struct LocationChannelsSheet: View {
// Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
torToggleSection
Button(action: {
openSystemLocationSettings()
}) {
@@ -409,8 +411,36 @@ struct LocationChannelsSheet: View {
}
}
// MARK: - Standardized Colors
// MARK: - TOR Toggle & Standardized Colors
extension LocationChannelsSheet {
private var torToggleBinding: Binding<Bool> {
Binding(
get: { network.userTorEnabled },
set: { network.setUserTorEnabled($0) }
)
}
private var torToggleSection: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text("tor routing")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(.primary)
Text("hides your ip for location channels. recommended: on.")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
@@ -419,6 +449,34 @@ extension LocationChannelsSheet {
}
}
private struct IRCToggleStyle: ToggleStyle {
let accent: Color
func makeBody(configuration: Configuration) -> some View {
Button(action: { configuration.isOn.toggle() }) {
HStack(spacing: 12) {
configuration.label
Spacer()
Text(configuration.isOn ? "on" : "off")
.textCase(.uppercase)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(accent.opacity(configuration.isOn ? 0.18 : 0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(accent.opacity(configuration.isOn ? 0.35 : 0.15), lineWidth: 1)
)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Coverage helpers
extension LocationChannelsSheet {
private func coverageString(forPrecision len: Int) -> String {
@@ -35,8 +35,9 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
processShare()
DispatchQueue.global().async {
self.processShare()
}
}
// MARK: - Processing
@@ -163,7 +164,7 @@ final class ShareViewController: UIViewController {
statusLabel.text = msg
// Complete shortly after showing status
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
+3 -3
View File
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "REMOTE123",
senderPeer: "REMOTE123",
mentions: nil
)
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "PACKET123",
senderPeer: "PACKET123",
mentions: nil
)
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: "TEST123",
senderPeer: "TEST123",
mentions: nil
)
@@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
@@ -439,7 +439,7 @@ final class PublicChatE2ETests: XCTestCase {
if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID != node.peerID else { return }
guard message.senderPeer?.id != node.peerID else { return }
// Create relay message
let relayMessage = BitchatMessage(
@@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
@@ -627,7 +627,7 @@ final class IntegrationTests: XCTestCase {
guard packet.ttl > 1 else { return }
if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return }
guard message.senderPeer?.id != node.peerID else { return }
let relayMessage = BitchatMessage(
id: message.id,
@@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase {
originalSender: message.isRelay ? message.originalSender : message.sender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID,
senderPeerID: message.senderPeer?.id,
mentions: message.mentions
)
+1 -1
View File
@@ -331,7 +331,7 @@ final class MockBLEService: NSObject {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
for neighbor in neighbors() {
// Avoid immediate echo loopback to sender if known
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue }
var relay = packet
relay.ttl = nextTTL
neighbor.simulateIncomingPacket(relay)
@@ -204,7 +204,7 @@ final class BinaryProtocolTests: XCTestCase {
XCTAssertEqual(decodedMessage.content, message.content)
XCTAssertEqual(decodedMessage.sender, message.sender)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeerID)
XCTAssertEqual(decodedMessage.senderPeerID, message.senderPeer?.id)
XCTAssertEqual(decodedMessage.isPrivate, message.isPrivate)
// Timestamp should be close (within 1 second due to conversion)
+1 -1
View File
@@ -44,7 +44,7 @@ final class TestHelpers {
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
senderPeer: senderPeerID,
mentions: mentions
)
}