Compare commits

..
Author SHA1 Message Date
jackandGitHub 0aaa8cc28f Merge branch 'main' into fix/deduplication-service-thread-safety 2026-01-04 13:56:35 -10:00
jackandGitHub 507cb19b91 Merge branch 'main' into fix/deduplication-service-thread-safety 2026-01-04 13:42:17 -10:00
jackandClaude Opus 4.5 6cc3a8cde7 fix: add @MainActor to MessageDeduplicationService for thread safety
Added @MainActor annotation to both LRUDeduplicationCache and
MessageDeduplicationService classes. This provides compile-time
enforcement of thread safety since all callers (ChatViewModel,
NostrRelayManager) are already on MainActor.

Benefits:
- Compile-time enforcement prevents future misuse
- Simpler than internal locking mechanisms
- Consistent with existing patterns in codebase

Also adds:
- @MainActor annotation to existing test suites
- 5 new concurrency tests verifying thread safety behavior

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 13:29:39 -10:00
2448 changed files with 358894 additions and 23133 deletions
+40 -11
View File
@@ -25,18 +25,47 @@ jobs:
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./relays/online_relays_gps.csv mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Check for changes - name: Configure git
id: git-check
run: | run: |
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT git config user.email "action@github.com"
git config user.name "GitHub Action"
- name: Commit and push changes - name: Create update branch if changes
if: steps.git-check.outputs.changes == 'true' id: create_branch
run: | run: |
git config --local user.email "action@github.com" # exit early if no changes
git config --local user.name "GitHub Action" if git diff --quiet --relays/online_relays_gps.csv; then
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
fi
# branch name with timestamp
BRANCH="update-georelays-$(date -u +%Y%m%dT%H%M%SZ)"
git checkout -b "$BRANCH"
git add relays/online_relays_gps.csv git add relays/online_relays_gps.csv
git commit -m "Automated update of relay data - $(date -u)" git commit -m "Automated update of relay data - $(date -u --rfc-3339=seconds)"
git push echo "changed=true" >> $GITHUB_OUTPUT
env: echo "branch=$BRANCH" >> $GITHUB_OUTPUT
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Push branch
if: steps.create_branch.outputs.changed == 'true'
run: |
git push --set-upstream origin "${{ steps.create_branch.outputs.branch }}"
- name: Create pull request
if: steps.create_branch.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Automated update of relay data
branch: ${{ steps.create_branch.outputs.branch }}
base: main
title: Automated update of relay data
body: |
This PR was created automatically by the scheduled workflow. It updates relays/online_relays_gps.csv from the GeoRelays source.
labels: automated, georelays
- name: No changes
if: steps.create_branch.outputs.changed != 'true'
run: echo "No changes to relays/online_relays_gps.csv"
+7 -22
View File
@@ -5,38 +5,23 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
branches:
- main
jobs: jobs:
test: test:
name: Run Swift Tests (${{ matrix.name }}) name: Run Swift Tests
runs-on: macos-latest runs-on: macos-latest
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
matrix:
include:
- name: app
path: .
- name: BitLogger
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Swift - name: Set up Swift
uses: swift-actions/setup-swift@v3 uses: swift-actions/setup-swift@v2
- name: Cache build artifacts - name: Build the package
uses: actions/cache@v5 run: swift build
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests - name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }} run: swift test --parallel
-1
View File
@@ -8,7 +8,6 @@ plans/
## AI ## AI
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
.claude/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
+1 -2
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.1 MARKETING_VERSION = 1.5.0
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
@@ -9,4 +9,3 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
APP_GROUP_ID = group.chat.bitchat
+3 -9
View File
@@ -16,8 +16,7 @@ let package = Package(
), ),
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Arti"), .package(path: "localPackages/Tor"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
@@ -26,15 +25,13 @@ let package = Package(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Arti") .product(name: "Tor", package: "Tor")
], ],
path: "bitchat", path: "bitchat",
exclude: [ exclude: [
"Info.plist", "Info.plist",
"Assets.xcassets", "Assets.xcassets",
"_PreviewHelpers/PreviewAssets.xcassets",
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard", "LaunchScreen.storyboard",
@@ -46,10 +43,7 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "bitchatTests", name: "bitchatTests",
dependencies: [ dependencies: ["bitchat"],
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
],
path: "bitchatTests", path: "bitchatTests",
exclude: [ exclude: [
"Info.plist", "Info.plist",
+3
View File
@@ -8,6 +8,9 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622) 📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License ## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details. This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
+11 -31
View File
@@ -10,12 +10,11 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; }; A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; }; A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; }; E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@@ -158,16 +157,15 @@
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
B5A5CC493FFB3D8966548140 /* Frameworks */ = { B5A5CC493FFB3D8966548140 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
files = ( files = (
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
@@ -227,7 +225,6 @@
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */, A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -308,7 +305,6 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */, A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -349,8 +345,7 @@
packageReferences = ( packageReferences = (
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
@@ -555,7 +550,6 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -566,7 +560,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -616,7 +610,6 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -627,7 +620,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -663,7 +656,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -755,7 +748,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1; MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -916,17 +909,13 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = { A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger; relativePath = localPackages/BitLogger;
}; };
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = { A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/Arti; relativePath = localPackages/Tor;
}; };
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
@@ -935,8 +924,8 @@
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1"; repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
requirement = { requirement = {
kind = exactVersion; kind = upToNextMajorVersion;
version = 0.21.1; minimumVersion = 0.21.1;
}; };
}; };
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
@@ -947,15 +936,6 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K; productName = P256K;
}; };
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = { A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = BitLogger; productName = BitLogger;
+5 -5
View File
@@ -8,7 +8,6 @@
import Tor import Tor
import SwiftUI import SwiftUI
import BitFoundation
import UserNotifications import UserNotifications
@main @main
@@ -64,10 +63,6 @@ struct BitchatApp: App {
// Initialize network activation policy; will start Tor/Nostr only when allowed // Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start() NetworkActivationService.shared.start()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content // Check for shared content
checkForSharedContent() checkForSharedContent()
} }
@@ -280,3 +275,8 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
} }
} }
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
@@ -9,18 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0 @Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0 @Published private(set) var progress: Double = 0
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
return Int(duration.rounded())
}
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
var remainingSeconds: Int {
let remaining = max(0, duration - currentTime)
return min(roundedDuration, Int(ceil(remaining)))
}
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
private var timer: Timer? private var timer: Timer?
private var url: URL private var url: URL
+99 -75
View File
@@ -2,7 +2,8 @@ import Foundation
import AVFoundation import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings. /// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder { /// Recording runs on an internal serial queue to avoid AVAudioSession contention.
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
enum RecorderError: Error { enum RecorderError: Error {
case microphoneAccessDenied case microphoneAccessDenied
case recorderInitializationFailed case recorderInitializationFailed
@@ -11,16 +12,21 @@ actor VoiceRecorder {
static let shared = VoiceRecorder() static let shared = VoiceRecorder()
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5 private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120 private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
private var currentURL: URL? private var currentURL: URL?
private var stopWorkItem: DispatchWorkItem?
private override init() {
super.init()
}
// MARK: - Permissions // MARK: - Permissions
nonisolated @discardableResult
func requestPermission() async -> Bool { func requestPermission() async -> Bool {
#if os(iOS) #if os(iOS)
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
@@ -41,88 +47,106 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle // MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL { func startRecording() throws -> URL {
if recorder?.isRecording == true { try queue.sync {
throw RecorderError.recordingInProgress if recorder?.isRecording == true {
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
stopWorkItem?.cancel()
stopWorkItem = nil
return outputURL
} }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
return outputURL
} }
func stopRecording() async -> URL? { func stopRecording(completion: @escaping (URL?) -> Void) {
guard let recorder, recorder.isRecording else { queue.async { [weak self] in
return currentURL guard let self = self, let recorder = self.recorder, recorder.isRecording else {
completion(self?.currentURL)
return
}
let item = DispatchWorkItem { [weak self] in
guard let self = self else { return }
recorder.stop()
self.cleanupSession()
let url = self.currentURL
self.recorder = nil
self.currentURL = url
completion(url)
}
self.stopWorkItem = item
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
} }
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
}
return sessionURL
} }
func cancelRecording() { func cancelRecording() {
if let recorder, recorder.isRecording { queue.async { [weak self] in
recorder.stop() guard let self = self else { return }
self.stopWorkItem?.cancel()
self.stopWorkItem = nil
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
}
self.cleanupSession()
if let url = self.currentURL {
try? FileManager.default.removeItem(at: url)
}
self.recorder = nil
self.currentURL = nil
} }
cleanupSession() }
if let currentURL {
try? FileManager.default.removeItem(at: currentURL) // MARK: - Metering
func currentAveragePower() -> Float {
queue.sync {
recorder?.updateMeters()
return recorder?.averagePower(forChannel: 0) ?? -160
} }
recorder = nil
currentURL = nil
} }
// MARK: - Helpers // MARK: - Helpers
-1
View File
@@ -81,7 +81,6 @@
/// ///
import Foundation import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model // MARK: - Three-Layer Identity Model
@@ -91,7 +91,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -332,15 +331,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
// Update nickname index // Update nickname index
if let previousClaimedNickname, if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
previousClaimedNickname != identity.claimedNickname { // Remove old nickname from index if changed
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint) if existingIdentity.claimedNickname != identity.claimedNickname {
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true { self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname) if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
}
} }
} }
@@ -532,16 +532,4 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cache.verifiedFingerprints return cache.verifiedFingerprints
} }
} }
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
queue.sync { ephemeralSessions[peerID] }
}
func debugLastInteraction(for fingerprint: String) -> Date? {
queue.sync { cache.lastInteractions[fingerprint] }
}
} }
+4 -6
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -39,12 +37,12 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string> <string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
-68
View File
@@ -1,68 +0,0 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
private struct Cache {
let filesDir: URL?
static let shared = Cache()
private init() {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
self.filesDir = filesDir
} catch {
filesDir = nil
}
}
}
func mediaAttachment(for nickname: String) -> Media? {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
return directory.appendingPathComponent(filename)
}
if let url = url(for: .audio) {
return .voice(url)
}
if let url = url(for: .image) {
return .image(url)
}
return nil
}
}
+1 -2
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
/// Represents a user-visible message in the BitChat system. /// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
@@ -339,7 +338,7 @@ extension BitchatMessage {
extension Array where Element == BitchatMessage { extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] { func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false } let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else { guard arr.count > 1 else {
return arr return arr
} }
+3 -8
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
/// The core packet structure for all BitChat protocol messages. /// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network, /// Encapsulates all data needed for routing through the mesh network,
@@ -23,9 +22,8 @@ struct BitchatPacket: Codable {
var signature: Data? var signature: Data?
var ttl: UInt8 var ttl: UInt8
var route: [Data]? var route: [Data]?
var isRSR: Bool
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) { init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
self.version = version self.version = version
self.type = type self.type = type
self.senderID = senderID self.senderID = senderID
@@ -35,11 +33,10 @@ struct BitchatPacket: Codable {
self.signature = signature self.signature = signature
self.ttl = ttl self.ttl = ttl
self.route = route self.route = route
self.isRSR = isRSR
} }
// Convenience initializer for new binary format // Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) { init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
self.version = 1 self.version = 1
self.type = type self.type = type
// Convert hex string peer ID to binary data (8 bytes) // Convert hex string peer ID to binary data (8 bytes)
@@ -59,7 +56,6 @@ struct BitchatPacket: Codable {
self.signature = nil self.signature = nil
self.ttl = ttl self.ttl = ttl
self.route = nil self.route = nil
self.isRSR = isRSR
} }
var data: Data? { var data: Data? {
@@ -89,8 +85,7 @@ struct BitchatPacket: Codable {
signature: nil, // Remove signature for signing signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version, version: version,
route: route, route: route
isRSR: false // RSR flag is mutable and not part of the signature
) )
return BinaryProtocol.encode(unsignedPacket) return BinaryProtocol.encode(unsignedPacket)
} }
-1
View File
@@ -1,6 +1,5 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable { struct BitchatPeer: Equatable {
@@ -1,27 +1,15 @@
// //
// PeerID.swift // PeerID.swift
// BitFoundation // bitchat
// //
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import struct Foundation.Data import Foundation
import struct Foundation.CharacterSet
public struct PeerID: Equatable, Hashable, Sendable { struct PeerID: Equatable, Hashable {
enum Constants { enum Prefix: String, CaseIterable {
/// 16
static let nostrConvKeyPrefixLength = 16
/// 8
static let nostrShortKeyDisplayLength = 8
/// 64
fileprivate static let maxIDLength = 64
/// 16
fileprivate static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
public enum Prefix: String, CaseIterable, Sendable {
/// When no prefix is provided /// When no prefix is provided
case empty = "" case empty = ""
/// `"mesh:"` /// `"mesh:"`
@@ -36,13 +24,13 @@ public struct PeerID: Equatable, Hashable, Sendable {
case geoChat = "nostr:" case geoChat = "nostr:"
} }
public let prefix: Prefix let prefix: Prefix
/// Returns the actual value without any prefix /// Returns the actual value without any prefix
public let bare: String let bare: String
/// Returns the full `id` value by combining `(prefix + bare)` /// Returns the full `id` value by combining `(prefix + bare)`
public var id: String { prefix.rawValue + bare } var id: String { prefix.rawValue + bare }
// Private so the callers have to go through a convenience init // Private so the callers have to go through a convenience init
private init(prefix: Prefix, bare: any StringProtocol) { private init(prefix: Prefix, bare: any StringProtocol) {
@@ -53,15 +41,15 @@ public struct PeerID: Equatable, Hashable, Sendable {
// MARK: - Convenience Inits // MARK: - Convenience Inits
public extension PeerID { extension PeerID {
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey` /// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
init(nostr_ pubKey: String) { init(nostr_ pubKey: String) {
self.init(prefix: .geoDM, bare: pubKey.prefix(Constants.nostrConvKeyPrefixLength)) self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
} }
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey` /// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
init(nostr pubKey: String) { init(nostr pubKey: String) {
self.init(prefix: .geoChat, bare: pubKey.prefix(Constants.nostrShortKeyDisplayLength)) self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
} }
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts /// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
@@ -98,7 +86,7 @@ public extension PeerID {
// MARK: - Noise Public Key Helpers // MARK: - Noise Public Key Helpers
public extension PeerID { extension PeerID {
/// Derive the stable 16-hex peer ID from a Noise static public key /// Derive the stable 16-hex peer ID from a Noise static public key
init(publicKey: Data) { init(publicKey: Data) {
self.init(str: publicKey.sha256Fingerprint().prefix(16)) self.init(str: publicKey.sha256Fingerprint().prefix(16))
@@ -116,11 +104,11 @@ public extension PeerID {
// MARK: - Codable // MARK: - Codable
extension PeerID: Codable { extension PeerID: Codable {
public init(from decoder: any Decoder) throws { init(from decoder: any Decoder) throws {
self.init(str: try decoder.singleValueContainer().decode(String.self)) self.init(str: try decoder.singleValueContainer().decode(String.self))
} }
public func encode(to encoder: any Encoder) throws { func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer() var container = encoder.singleValueContainer()
try container.encode(id) try container.encode(id)
} }
@@ -128,7 +116,7 @@ extension PeerID: Codable {
// MARK: - Helpers // MARK: - Helpers
public extension PeerID { extension PeerID {
var isEmpty: Bool { var isEmpty: Bool {
id.isEmpty id.isEmpty
} }
@@ -148,7 +136,7 @@ public extension PeerID {
} }
} }
public extension PeerID { extension PeerID {
var routingData: Data? { var routingData: Data? {
if let direct = Data(hexString: id), direct.count == 8 { return direct } if let direct = Data(hexString: id), direct.count == 8 { return direct }
if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData } if let bareData = Data(hexString: bare), bareData.count == 8 { return bareData }
@@ -164,7 +152,12 @@ public extension PeerID {
// MARK: - Validation // MARK: - Validation
public extension PeerID { extension PeerID {
private enum Constants {
static let maxIDLength = 64
static let hexIDLength = 16 // 8 bytes = 16 hex chars
}
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
var isValid: Bool { var isValid: Bool {
if prefix != .empty { if prefix != .empty {
@@ -213,7 +206,7 @@ public extension PeerID {
// MARK: - Comparable // MARK: - Comparable
extension PeerID: Comparable { extension PeerID: Comparable {
public static func < (lhs: PeerID, rhs: PeerID) -> Bool { static func < (lhs: PeerID, rhs: PeerID) -> Bool {
lhs.id < rhs.id lhs.id < rhs.id
} }
} }
@@ -222,7 +215,7 @@ extension PeerID: Comparable {
extension PeerID: CustomStringConvertible { extension PeerID: CustomStringConvertible {
/// So it returns the actual `id` like before even inside another String /// So it returns the actual `id` like before even inside another String
public var description: String { var description: String {
id id
} }
} }
-1
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
struct ReadReceipt: Codable { struct ReadReceipt: Codable {
let originalMessageID: String let originalMessageID: String
+2 -25
View File
@@ -9,16 +9,12 @@ struct RequestSyncPacket {
let m: UInt32 let m: UInt32
let data: Data let data: Data
let types: SyncTypeFlags? let types: SyncTypeFlags?
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
self.p = p self.p = p
self.m = m self.m = m
self.data = data self.data = data
self.types = types self.types = types
self.sinceTimestamp = sinceTimestamp
self.fragmentIdFilter = fragmentIdFilter
} }
func encode() -> Data { func encode() -> Data {
@@ -40,13 +36,6 @@ struct RequestSyncPacket {
if let typesData = types?.toData() { if let typesData = types?.toData() {
putTLV(0x04, typesData) putTLV(0x04, typesData)
} }
if let ts = sinceTimestamp {
var tsBE = ts.bigEndian
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
}
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
putTLV(0x06, fidData)
}
return out return out
} }
@@ -56,8 +45,6 @@ struct RequestSyncPacket {
var m: UInt32? = nil var m: UInt32? = nil
var payload: Data? = nil var payload: Data? = nil
var types: SyncTypeFlags? = nil var types: SyncTypeFlags? = nil
var sinceTimestamp: UInt64? = nil
var fragmentIdFilter: String? = nil
while off + 3 <= data.count { while off + 3 <= data.count {
let t = Int(data[off]); off += 1 let t = Int(data[off]); off += 1
@@ -81,22 +68,12 @@ struct RequestSyncPacket {
if let decoded = SyncTypeFlags.decode(v) { if let decoded = SyncTypeFlags.decode(v) {
types = decoded types = decoded
} }
case 0x05:
if v.count == 8 {
var ts: UInt64 = 0
for b in v { ts = (ts << 8) | UInt64(b) }
sinceTimestamp = ts
}
case 0x06:
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default: default:
break // forward compatible; ignore unknown TLVs break // forward compatible; ignore unknown TLVs
} }
} }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil } guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter) return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
} }
} }
+10 -103
View File
@@ -165,12 +165,8 @@ final class NoiseCipherState {
// MARK: - Sliding Window Replay Protection // MARK: - Sliding Window Replay Protection
/// Check if nonce is valid for replay protection /// Check if nonce is valid for replay protection
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
private func isValidNonce(_ receivedNonce: UInt64) -> Bool { private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest) if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
return false // Too old, outside window return false // Too old, outside window
} }
@@ -352,19 +348,15 @@ final class NoiseCipherState {
do { do {
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData) let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
// BCH-01-010: Atomic nonce state update
// Both replay window marking and nonce increment must complete together
// to prevent state desynchronization. We perform both after successful
// decryption only, ensuring state consistency on any failure path.
if useExtractedNonce { if useExtractedNonce {
// Mark nonce as seen after successful decryption
markNonceAsSeen(decryptionNonce) markNonceAsSeen(decryptionNonce)
} }
nonce += 1 nonce += 1
return plaintext return plaintext
} catch { } catch {
// Decryption failed - nonce state remains unchanged (atomic rollback)
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)") SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
// Log authentication failures with nonce info
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption) SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
throw error throw error
} }
@@ -384,16 +376,6 @@ final class NoiseCipherState {
replayWindow[i] = 0 replayWindow[i] = 0
} }
} }
#if DEBUG
func setNonceForTesting(_ nonce: UInt64) {
self.nonce = nonce
}
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
try extractNonceFromCiphertextPayload(combinedPayload)
}
#endif
} }
// MARK: - Symmetric State // MARK: - Symmetric State
@@ -477,32 +459,9 @@ final class NoiseSymmetricState {
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce) let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce) let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
// BCH-01-010: Clear symmetric state after split per Noise spec
// The chaining key and hash should not be retained after handshake completes
clearSensitiveData()
return (c1, c2) return (c1, c2)
} }
/// BCH-01-010: Securely clear sensitive cryptographic state
/// Called after split() to clear chaining key and hash per Noise spec
func clearSensitiveData() {
// Clear chaining key by overwriting with zeros
let chainingKeyCount = chainingKey.count
chainingKey = Data(repeating: 0, count: chainingKeyCount)
// Clear hash by overwriting with zeros
let hashCount = hash.count
hash = Data(repeating: 0, count: hashCount)
// Clear the internal cipher state
cipherState.clearSensitiveData()
}
deinit {
clearSensitiveData()
}
// HKDF implementation // HKDF implementation
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] { private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey)) let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
@@ -595,9 +554,8 @@ final class NoiseHandshakeState {
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
_ = symmetricState.getHandshakeHash()
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
symmetricState.mixHash(localStatic.rawRepresentation)
} }
} }
} }
@@ -849,20 +807,16 @@ final class NoiseHandshakeState {
return currentPattern >= messagePatterns.count return currentPattern >= messagePatterns.count
} }
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) { func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
guard isHandshakeComplete() else { guard isHandshakeComplete() else {
throw NoiseError.handshakeNotComplete throw NoiseError.handshakeNotComplete
} }
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
let finalHandshakeHash = symmetricState.getHandshakeHash()
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce) let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
// Initiator uses c1 for sending, c2 for receiving // Initiator uses c1 for sending, c2 for receiving
// Responder uses c2 for sending, c1 for receiving // Responder uses c2 for sending, c1 for receiving
let ciphers = role == .initiator ? (c1, c2) : (c2, c1) return role == .initiator ? (c1, c2) : (c2, c1)
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
} }
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? { func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
@@ -872,20 +826,6 @@ final class NoiseHandshakeState {
func getHandshakeHash() -> Data { func getHandshakeHash() -> Data {
return symmetricState.getHandshakeHash() return symmetricState.getHandshakeHash()
} }
#if DEBUG
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
try performDHOperation(pattern)
}
func setCurrentPatternForTesting(_ currentPattern: Int) {
self.currentPattern = currentPattern
}
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
self.remoteEphemeralPublic = key
}
#endif
} }
// MARK: - Pattern Extensions // MARK: - Pattern Extensions
@@ -937,44 +877,19 @@ enum NoiseError: Error {
case nonceExceeded case nonceExceeded
} }
// MARK: - Constant-Time Operations
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
/// This function compares two Data objects in constant time, preventing
/// information leakage via timing analysis.
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
guard a.count == b.count else { return false }
var result: UInt8 = 0
for i in 0..<a.count {
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
}
return result == 0
}
/// BCH-01-010: Constant-time check if all bytes are zero
private func constantTimeIsZero(_ data: Data) -> Bool {
var result: UInt8 = 0
for byte in data {
result |= byte
}
return result == 0
}
// MARK: - Key Validation // MARK: - Key Validation
extension NoiseHandshakeState { extension NoiseHandshakeState {
/// Validate a Curve25519 public key /// Validate a Curve25519 public key
/// Checks for weak/invalid keys that could compromise security /// Checks for weak/invalid keys that could compromise security
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey { static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
// Check key length // Check key length
guard keyData.count == 32 else { guard keyData.count == 32 else {
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
// BCH-01-010: Constant-time check for all-zero key (point at infinity) // Check for all-zero key (point at infinity)
if constantTimeIsZero(keyData) { if keyData.allSatisfy({ $0 == 0 }) {
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
@@ -999,16 +914,8 @@ extension NoiseHandshakeState {
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
] ]
// BCH-01-010: Constant-time check against known bad points // Check against known bad points
// We check all points and accumulate matches to avoid early exit timing leaks if lowOrderPoints.contains(keyData) {
var foundBadPoint = false
for badPoint in lowOrderPoints {
if constantTimeCompare(keyData, badPoint) {
foundBadPoint = true
}
}
if foundBadPoint {
SecureLogger.warning("Low-order point detected", category: .security) SecureLogger.warning("Low-order point detected", category: .security)
throw NoiseError.invalidPublicKey throw NoiseError.invalidPublicKey
} }
-1
View File
@@ -7,7 +7,6 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
final class NoiseRateLimiter { final class NoiseRateLimiter {
+6 -7
View File
@@ -9,7 +9,6 @@
import BitLogger import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
class NoiseSession { class NoiseSession {
let peerID: PeerID let peerID: PeerID
@@ -103,8 +102,8 @@ class NoiseSession {
// Check if handshake is complete // Check if handshake is complete
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state) // Get transport ciphers
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true) let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send sendCipher = send
receiveCipher = receive receiveCipher = receive
@@ -112,7 +111,7 @@ class NoiseSession {
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding // Store handshake hash for channel binding
handshakeHash = hash handshakeHash = handshake.getHandshakeHash()
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
@@ -129,8 +128,8 @@ class NoiseSession {
// Check if handshake is complete after writing // Check if handshake is complete after writing
if handshake.isHandshakeComplete() { if handshake.isHandshakeComplete() {
// Get transport ciphers and handshake hash (hash captured before split clears state) // Get transport ciphers
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true) let (send, receive) = try handshake.getTransportCiphers(useExtractedNonce: true)
sendCipher = send sendCipher = send
receiveCipher = receive receiveCipher = receive
@@ -138,7 +137,7 @@ class NoiseSession {
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey() remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
// Store handshake hash for channel binding // Store handshake hash for channel binding
handshakeHash = hash handshakeHash = handshake.getHandshakeHash()
state = .established state = .established
handshakeState = nil // Clear handshake state handshakeState = nil // Clear handshake state
+12 -24
View File
@@ -9,13 +9,11 @@
import BitLogger import BitLogger
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
@@ -25,28 +23,8 @@ final class NoiseSessionManager {
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
}
} }
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management // MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? { func getSession(for peerID: PeerID) -> NoiseSession? {
@@ -88,7 +66,12 @@ final class NoiseSessionManager {
} }
// Create new initiator session // Create new initiator session
let session = sessionFactory(peerID, .initiator) let session = SecureNoiseSession(
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session sessions[peerID] = session
do { do {
@@ -134,7 +117,12 @@ final class NoiseSessionManager {
// Get or create session // Get or create session
let session: NoiseSession let session: NoiseSession
if shouldCreateNew { if shouldCreateNew {
let newSession = sessionFactory(peerID, .responder) let newSession = SecureNoiseSession(
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession sessions[peerID] = newSession
session = newSession session = newSession
} else { } else {
+1 -5
View File
@@ -10,7 +10,7 @@ import Foundation
final class SecureNoiseSession: NoiseSession { final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0 private(set) var messageCount: UInt64 = 0
private var sessionStartTime = Date() private let sessionStartTime = Date()
private(set) var lastActivityTime = Date() private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data { override func encrypt(_ plaintext: Data) throws -> Data {
@@ -77,9 +77,5 @@ final class SecureNoiseSession: NoiseSession {
func setMessageCountForTesting(_ count: UInt64) { func setMessageCountForTesting(_ count: UInt64) {
messageCount = count messageCount = count
} }
func setSessionStartTimeForTesting(_ date: Date) {
sessionStartTime = date
}
#endif #endif
} }
+104 -214
View File
@@ -8,154 +8,39 @@ import AppKit
#endif #endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
var notificationCenter: NotificationCenter
var now: () -> Date
var remoteURL: URL
var fetchInterval: TimeInterval
var refreshCheckInterval: TimeInterval
var retryInitialSeconds: TimeInterval
var retryMaxSeconds: TimeInterval
var awaitTorReady: @Sendable () async -> Bool
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
var readData: (URL) -> Data?
var writeData: (Data, URL) throws -> Void
var cacheURL: () -> URL?
var bundledCSVURLs: () -> [URL]
var currentDirectoryPath: () -> String?
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
}
private extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
@MainActor @MainActor
final class GeoRelayDirectory { final class GeoRelayDirectory {
private final class CleanupState { struct Entry: Hashable {
let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = []
var refreshTimer: Timer?
var retryTask: Task<Void, Never>?
init(notificationCenter: NotificationCenter) {
self.notificationCenter = notificationCenter
}
deinit {
observers.forEach { notificationCenter.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
}
struct Entry: Hashable, Sendable {
let host: String let host: String
let lat: Double let lat: Double
let lon: Double let lon: Double
} }
private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: String)
case torNotReady
case invalidData
case network(String)
}
static let shared = GeoRelayDirectory() static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = [] private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let dependencies: GeoRelayDirectoryDependencies private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let cleanupState: CleanupState private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
private var refreshTimer: Timer?
private var retryTask: Task<Void, Never>?
private var retryAttempt: Int = 0 private var retryAttempt: Int = 0
private var isFetching: Bool = false private var isFetching: Bool = false
private var observers: [NSObjectProtocol] = []
private init() { private init() {
self.dependencies = .live()
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries() entries = loadLocalEntries()
if dependencies.autoStart { registerObservers()
registerObservers() startRefreshTimer()
startRefreshTimer() prefetchIfNeeded()
prefetchIfNeeded()
}
} }
internal init(dependencies: GeoRelayDirectoryDependencies) { deinit {
self.dependencies = dependencies observers.forEach { NotificationCenter.default.removeObserver($0) }
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter) refreshTimer?.invalidate()
entries = loadLocalEntries() retryTask?.cancel()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
} }
/// Returns up to `count` relay URLs (wss://) closest to the geohash center. /// Returns up to `count` relay URLs (wss://) closest to the geohash center.
@@ -198,13 +83,13 @@ final class GeoRelayDirectory {
func prefetchIfNeeded(force: Bool = false) { func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return } guard !isFetching else { return }
let now = dependencies.now() let now = Date()
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
if !force { if !force {
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return } guard now.timeIntervalSince(last) >= fetchInterval else { return }
} else if last != .distantPast, } else if last != .distantPast,
now.timeIntervalSince(last) < dependencies.retryInitialSeconds { now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago. // Skip forced fetches if we just refreshed moments ago.
return return
} }
@@ -218,73 +103,51 @@ final class GeoRelayDirectory {
isFetching = true isFetching = true
let request = URLRequest( let request = URLRequest(
url: dependencies.remoteURL, url: remoteURL,
cachePolicy: .reloadIgnoringLocalCacheData, cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15 timeoutInterval: 15
) )
let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData()
Task { [weak self] in Task.detached { [weak self] in
guard let self else { return } guard let self else { return }
let outcome = await Self.fetchRemoteOutcome( let ready = await TorManager.shared.awaitReady()
request: request, if !ready {
awaitTorReady: awaitTorReady, await self.handleFetchFailure(.torNotReady)
fetchData: fetchData return
)
switch outcome {
case .success(let parsed, let csv):
self.handleFetchSuccess(entries: parsed, csv: csv)
case .torNotReady:
self.handleFetchFailure(.torNotReady)
case .invalidData:
self.handleFetchFailure(.invalidData)
case .network(let description):
self.handleFetchFailure(.network(description))
} }
}
}
nonisolated private static func fetchRemoteOutcome(
request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) {
let ready = await awaitTorReady()
guard ready else { return .torNotReady }
do { do {
let data = try await fetchData(request) let (data, _) = try await TorURLSession.shared.session.data(for: request)
guard let text = String(data: data, encoding: .utf8) else { guard let text = String(data: data, encoding: .utf8) else {
return .invalidData await self.handleFetchFailure(.invalidData)
return
} }
let parsed = Self.parseCSV(text) let parsed = GeoRelayDirectory.parseCSV(text)
guard !parsed.isEmpty else { guard !parsed.isEmpty else {
return .invalidData await self.handleFetchFailure(.invalidData)
return
} }
return .success(entries: parsed, csv: text) await self.handleFetchSuccess(entries: parsed, csv: text)
} catch { } catch {
return .network(error.localizedDescription) await self.handleFetchFailure(.network(error))
} }
}.value }
} }
private enum FetchFailure { private enum FetchFailure {
case torNotReady case torNotReady
case invalidData case invalidData
case network(String) case network(Error)
} }
@MainActor @MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) { private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed entries = parsed
persistCache(csv) persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) UserDefaults.standard.set(Date(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false isFetching = false
retryAttempt = 0 retryAttempt = 0
@@ -298,8 +161,8 @@ final class GeoRelayDirectory {
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session) SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData: case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session) SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let errorDescription): case .network(let error):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session) SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
} }
isFetching = false isFetching = false
scheduleRetry() scheduleRetry()
@@ -308,34 +171,32 @@ final class GeoRelayDirectory {
@MainActor @MainActor
private func scheduleRetry() { private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10) retryAttempt = min(retryAttempt + 1, 10)
let base = dependencies.retryInitialSeconds let base = TransportConfig.geoRelayRetryInitialSeconds
let maxDelay = dependencies.retryMaxSeconds let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0))) let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated)) let delay = min(maxDelay, max(base, calculated))
cancelRetry() cancelRetry()
cleanupState.retryTask = Task { [weak self] in retryTask = Task { [weak self] in
guard let self else { return } let nanoseconds = UInt64(delay * 1_000_000_000)
await self.dependencies.retrySleep(delay) try? await Task.sleep(nanoseconds: nanoseconds)
guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
self.prefetchIfNeeded(force: true) self?.prefetchIfNeeded(force: true)
} }
} }
} }
@MainActor @MainActor
private func cancelRetry() { private func cancelRetry() {
cleanupState.retryTask?.cancel() retryTask?.cancel()
cleanupState.retryTask = nil retryTask = nil
} }
private func persistCache(_ text: String) { private func persistCache(_ text: String) {
guard let url = dependencies.cacheURL() else { return } guard let url = cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try dependencies.writeData(data, url) try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch { } catch {
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
@@ -344,18 +205,22 @@ final class GeoRelayDirectory {
// MARK: - Loading // MARK: - Loading
private func loadLocalEntries() -> [Entry] { private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present // Prefer cached file if present
if let cache = dependencies.cacheURL(), if let cache = cacheURL(),
let data = dependencies.readData(cache), let data = try? Data(contentsOf: cache),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
} }
// Try bundled resource(s) // Try bundled resource(s)
let bundleCandidates = dependencies.bundledCSVURLs() let bundleCandidates = [
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates { for url in bundleCandidates {
if let data = dependencies.readData(url), if let data = try? Data(contentsOf: url),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
@@ -363,8 +228,8 @@ final class GeoRelayDirectory {
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(), if let cwd = FileManager.default.currentDirectoryPath as String?,
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
@@ -377,9 +242,10 @@ final class GeoRelayDirectory {
var result: Set<Entry> = [] var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() { for (idx, raw) in lines.enumerated() {
guard let line = raw.trimmedOrNilIfEmpty else { continue } let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue } if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { $0.trimmed } let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3 else { continue } guard parts.count >= 3 else { continue }
var host = parts[0] var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "") host = host.replacingOccurrences(of: "https://", with: "")
@@ -393,9 +259,25 @@ final class GeoRelayDirectory {
return Array(result) return Array(result)
} }
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch {
return nil
}
}
// MARK: - Observers & Timers // MARK: - Observers & Timers
private func registerObservers() { private func registerObservers() {
let center = dependencies.notificationCenter let center = NotificationCenter.default
let torReady = center.addObserver( let torReady = center.addObserver(
forName: .TorDidBecomeReady, forName: .TorDidBecomeReady,
@@ -407,26 +289,38 @@ final class GeoRelayDirectory {
self.prefetchIfNeeded(force: true) self.prefetchIfNeeded(force: true)
} }
} }
cleanupState.observers.append(torReady) observers.append(torReady)
if let activeNotificationName = dependencies.activeNotificationName { #if os(iOS)
let didBecomeActive = center.addObserver( let didBecomeActive = center.addObserver(
forName: activeNotificationName, forName: UIApplication.didBecomeActiveNotification,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
self.prefetchIfNeeded() self.prefetchIfNeeded()
}
} }
cleanupState.observers.append(didBecomeActive)
} }
observers.append(didBecomeActive)
#elseif os(macOS)
let didBecomeActive = center.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#endif
} }
private func startRefreshTimer() { private func startRefreshTimer() {
cleanupState.refreshTimer?.invalidate() refreshTimer?.invalidate()
let interval = dependencies.refreshCheckInterval let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
guard interval > 0 else { return } guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
@@ -435,13 +329,9 @@ final class GeoRelayDirectory {
self.prefetchIfNeeded() self.prefetchIfNeeded()
} }
} }
cleanupState.refreshTimer = timer refreshTimer = timer
RunLoop.main.add(timer, forMode: .common) RunLoop.main.add(timer, forMode: .common)
} }
var debugRetryAttempt: Int { retryAttempt }
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
var debugObserverCount: Int { cleanupState.observers.count }
} }
// MARK: - Distance // MARK: - Distance
-1
View File
@@ -1,5 +1,4 @@
import Foundation import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter // MARK: - BitChat-over-Nostr Adapter
+2 -41
View File
@@ -18,7 +18,6 @@ struct NostrProtocol {
case seal = 13 // NIP-17 sealed event case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001
} }
/// Create a NIP-17 private message /// Create a NIP-17 private message
@@ -109,7 +108,7 @@ struct NostrProtocol {
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
@@ -126,24 +125,6 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a geohash presence heartbeat (kind 20001)
/// Must contain empty content and NO nickname tag
static func createGeohashPresenceEvent(
geohash: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [["g", geohash]]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: tags,
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
@@ -152,7 +133,7 @@ struct NostrProtocol {
nickname: String? = nil nickname: String? = nil
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
let event = NostrEvent( let event = NostrEvent(
@@ -529,26 +510,6 @@ struct NostrEvent: Codable {
return signed return signed
} }
/// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify.
func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
sigData.count == 64,
pubData.count == 32,
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
let (expectedId, eventHash) = try? calculateEventId(),
expectedId == id
else {
return false
}
var messageBytes = [UInt8](eventHash)
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) { private func calculateEventId() throws -> (String, Data) {
let serialized = [ let serialized = [
0, 0,
+74 -201
View File
@@ -4,100 +4,6 @@ import Network
import Combine import Combine
import Tor import Tor
protocol NostrRelayConnectionProtocol: AnyObject {
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
}
protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
}
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
private let base: URLSessionWebSocketTask
init(base: URLSessionWebSocketTask) {
self.base = base
}
func resume() {
base.resume()
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
base.cancel(with: closeCode, reason: reason)
}
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
base.send(message, completionHandler: completionHandler)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
base.receive(completionHandler: completionHandler)
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
base.sendPing(pongReceiveHandler: pongReceiveHandler)
}
}
private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
struct NostrRelayManagerDependencies {
var activationAllowed: () -> Bool
var userTorEnabled: () -> Bool
var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
}
private extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
@@ -135,11 +41,10 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:] private var connections: [String: URLSessionWebSocketTask] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
@@ -164,7 +69,9 @@ final class NostrRelayManager: ObservableObject {
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() } private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -172,17 +79,18 @@ final class NostrRelayManager: ObservableObject {
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer
private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect // Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
self.dependencies = .live() hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
hasMutualFavorites = dependencies.hasMutualFavorites() hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true) applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] favorites in .sink { [weak self] favorites in
guard let self = self else { return } guard let self = self else { return }
@@ -190,34 +98,7 @@ final class NostrRelayManager: ObservableObject {
self.applyDefaultRelayPolicy() self.applyDefaultRelayPolicy()
} }
.store(in: &cancellables) .store(in: &cancellables)
dependencies.locationPermissionPublisher LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
}
internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] state in .sink { [weak self] state in
guard let self = self else { return } guard let self = self else { return }
@@ -232,18 +113,20 @@ final class NostrRelayManager: ObservableObject {
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard networkService.activationAllowed else { return }
if shouldUseTor { if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in Task.detached {
guard let self = self else { return } let ready = await TorManager.shared.awaitReady()
if !ready { await MainActor.run {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) if !ready {
return 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 { SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
self.connectToRelay(relay.url) for relay in self.relays {
self.connectToRelay(relay.url)
}
} }
} }
} else { } else {
@@ -270,14 +153,15 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard networkService.activationAllowed else { return }
let targets = allowedRelayList(from: relayUrls) let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return } guard !targets.isEmpty else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer until Tor is fully ready; avoid queuing connection attempts early // Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if ready { self.ensureConnections(to: relayUrls) } let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
} }
return return
} }
@@ -294,12 +178,13 @@ final class NostrRelayManager: ObservableObject {
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard networkService.activationAllowed else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) } let ready = await TorManager.shared.awaitReady()
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
} }
return return
} }
@@ -371,21 +256,24 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard networkService.activationAllowed else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = dependencies.now() let now = Date()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return return
} }
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
// Defer subscription setup until Tor is ready; avoid queuing subs early // Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if ready { let ready = await TorManager.shared.awaitReady()
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE) await MainActor.run {
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
} }
} }
return return
@@ -412,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
} }
for url in urls { for url in candidateUrls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
@@ -461,7 +349,7 @@ final class NostrRelayManager: ObservableObject {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url) existing.insert(url)
} }
if dependencies.activationAllowed() { if networkService.activationAllowed {
ensureConnections(to: Self.defaultRelays) ensureConnections(to: Self.defaultRelays)
} }
} else { } else {
@@ -471,7 +359,6 @@ final class NostrRelayManager: ObservableObject {
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
} }
messageQueueLock.lock() messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() { for index in (0..<messageQueue.count).reversed() {
@@ -516,9 +403,10 @@ final class NostrRelayManager: ObservableObject {
// Send unsubscribe to all relays // Send unsubscribe to all relays
for (relayUrl, connection) in connections { for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true { if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in connection.send(.string(messageString)) { _ in
// Local state is cleared before sending so callers can re-subscribe immediately. Task { @MainActor in
self.subscriptions[relayUrl]?.remove(id)
}
} }
} }
} }
@@ -528,14 +416,14 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
guard dependencies.activationAllowed() else { return } guard networkService.activationAllowed else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
return return
} }
@@ -550,16 +438,19 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() { if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
dependencies.awaitTorReady { [weak self] ready in Task.detached { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if ready { self.connectToRelay(urlString) } let ready = await TorManager.shared.awaitReady()
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) } await MainActor.run {
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
} }
return return
} }
let session = dependencies.makeSession() let session = TorURLSession.shared.session
let task = session.webSocketTask(with: url) let task = session.webSocketTask(with: url)
connections[urlString] = task connections[urlString] = task
@@ -607,7 +498,7 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl] = nil pendingSubscriptions[relayUrl] = nil
} }
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) { private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
task.receive { [weak self] result in task.receive { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
@@ -617,7 +508,7 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl) NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
} }
} }
@@ -681,7 +572,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) { private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
@@ -713,11 +604,11 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected relays[index].isConnected = isConnected
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = dependencies.now() relays[index].lastConnectedAt = Date()
relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
relays[index].lastDisconnectedAt = dependencies.now() relays[index].lastDisconnectedAt = Date()
} }
} }
updateConnectionStatus() updateConnectionStatus()
@@ -733,7 +624,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() { if !networkService.activationAllowed {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
@@ -778,21 +669,19 @@ final class NostrRelayManager: ObservableObject {
maxBackoffInterval maxBackoffInterval
) )
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval) let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
Task { @MainActor [weak self] in guard let self = self else { return }
guard let self = self else { return } // Ignore stale scheduled reconnects from a previous generation
// Ignore stale scheduled reconnects from a previous generation guard gen == self.connectionGeneration else { return }
guard gen == self.connectionGeneration else { return } // Check if we should still reconnect (relay might have been removed)
// Check if we should still reconnect (relay might have been removed) if self.relays.contains(where: { $0.url == relayUrl }) {
if self.relays.contains(where: { $0.url == relayUrl }) { self.connectToRelay(relayUrl)
self.connectToRelay(relayUrl)
}
} }
} }
} }
@@ -806,7 +695,6 @@ final class NostrRelayManager: ObservableObject {
// Reset reconnection attempts // Reset reconnection attempts
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected // Disconnect if connected
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
@@ -828,20 +716,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
@@ -893,8 +767,7 @@ private enum ParsedInbound {
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any], let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict), let event = try? NostrEvent(from: eventDict) {
event.isValidSignature() {
self = .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return return
} }
@@ -1014,10 +887,10 @@ struct NostrFilter: Encodable {
return filter return filter
} }
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001) // For location channels: geohash-scoped ephemeral events (kind 20000)
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter { static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter() var filter = NostrFilter()
filter.kinds = [20000, 20001] filter.kinds = [20000]
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]] filter.tagFilters = ["g": [geohash]]
filter.limit = limit filter.limit = limit
+56 -1
View File
@@ -6,7 +6,62 @@
// //
import Foundation import Foundation
import BitFoundation import CryptoKit
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmingCharacters(in: .whitespaces)
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
+20 -24
View File
@@ -138,7 +138,6 @@ struct BinaryProtocol {
static let hasSignature: UInt8 = 0x02 static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04 static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08 static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10
} }
// Encode BitchatPacket to binary format // Encode BitchatPacket to binary format
@@ -162,9 +161,7 @@ struct BinaryProtocol {
} }
let lengthFieldBytes = lengthFieldSize(for: version) let lengthFieldBytes = lengthFieldSize(for: version)
let originalRoute = packet.route ?? []
// Route is only supported for v2+ packets (per SOURCE_ROUTING.md spec)
let originalRoute = (version >= 2) ? (packet.route ?? []) : []
if originalRoute.contains(where: { $0.isEmpty }) { return nil } if originalRoute.contains(where: { $0.isEmpty }) { return nil }
let sanitizedRoute: [Data] = originalRoute.map { hop in let sanitizedRoute: [Data] = originalRoute.map { hop in
if hop.count == senderIDSize { return hop } if hop.count == senderIDSize { return hop }
@@ -178,14 +175,13 @@ struct BinaryProtocol {
let hasRoute = !sanitizedRoute.isEmpty let hasRoute = !sanitizedRoute.isEmpty
let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0 let routeLength = hasRoute ? 1 + sanitizedRoute.count * senderIDSize : 0
let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0 let originalSizeFieldBytes = isCompressed ? lengthFieldBytes : 0
// payloadLength in header is payload-only (does NOT include route bytes) let payloadDataSize = routeLength + payload.count + originalSizeFieldBytes
let payloadDataSize = payload.count + originalSizeFieldBytes
if version == 1 && payloadDataSize > Int(UInt16.max) { return nil } if version == 1 && payloadDataSize > Int(UInt16.max) { return nil }
if version == 2 && payloadDataSize > Int(UInt32.max) { return nil } if version == 2 && payloadDataSize > Int(UInt32.max) { return nil }
guard let headerSize = headerSize(for: version) else { return nil } guard let headerSize = headerSize(for: version) else { return nil }
let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + routeLength let estimatedHeader = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize)
let estimatedPayload = payloadDataSize let estimatedPayload = payloadDataSize
let estimatedSignature = (packet.signature == nil ? 0 : signatureSize) let estimatedSignature = (packet.signature == nil ? 0 : signatureSize)
var data = Data() var data = Data()
@@ -203,9 +199,7 @@ struct BinaryProtocol {
if packet.recipientID != nil { flags |= Flags.hasRecipient } if packet.recipientID != nil { flags |= Flags.hasRecipient }
if packet.signature != nil { flags |= Flags.hasSignature } if packet.signature != nil { flags |= Flags.hasSignature }
if isCompressed { flags |= Flags.isCompressed } if isCompressed { flags |= Flags.isCompressed }
// HAS_ROUTE is only valid for v2+ packets if hasRoute { flags |= Flags.hasRoute }
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
if packet.isRSR { flags |= Flags.isRSR }
data.append(flags) data.append(flags)
if version == 2 { if version == 2 {
@@ -329,9 +323,6 @@ struct BinaryProtocol {
let hasRecipient = (flags & Flags.hasRecipient) != 0 let hasRecipient = (flags & Flags.hasRecipient) != 0
let hasSignature = (flags & Flags.hasSignature) != 0 let hasSignature = (flags & Flags.hasSignature) != 0
let isCompressed = (flags & Flags.isCompressed) != 0 let isCompressed = (flags & Flags.isCompressed) != 0
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
let isRSR = (flags & Flags.isRSR) != 0
let payloadLength: Int let payloadLength: Int
if version == 2 { if version == 2 {
@@ -343,7 +334,6 @@ struct BinaryProtocol {
} }
guard payloadLength >= 0 else { return nil } guard payloadLength >= 0 else { return nil }
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
guard let senderID = readData(senderIDSize) else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
@@ -353,24 +343,27 @@ struct BinaryProtocol {
if recipientID == nil { return nil } if recipientID == nil { return nil }
} }
// Route (optional, v2+ only): route bytes are NOT included in payloadLength
var route: [Data]? = nil var route: [Data]? = nil
if hasRoute { var remainingPayloadBytes = payloadLength
guard let routeCount = read8() else { return nil }
if (flags & Flags.hasRoute) != 0 {
guard remainingPayloadBytes >= 1, let routeCount = read8() else { return nil }
remainingPayloadBytes -= 1
if routeCount > 0 { if routeCount > 0 {
var hops: [Data] = [] var hops: [Data] = []
for _ in 0..<Int(routeCount) { for _ in 0..<Int(routeCount) {
guard let hop = readData(senderIDSize) else { return nil } guard remainingPayloadBytes >= senderIDSize,
let hop = readData(senderIDSize) else { return nil }
remainingPayloadBytes -= senderIDSize
hops.append(hop) hops.append(hop)
} }
route = hops route = hops
} }
} }
// Payload: payloadLength is exactly the payload size (+ compression preamble if compressed)
let payload: Data let payload: Data
if isCompressed { if isCompressed {
guard payloadLength >= lengthFieldBytes else { return nil } guard remainingPayloadBytes >= lengthFieldBytes else { return nil }
let originalSize: Int let originalSize: Int
if version == 2 { if version == 2 {
guard let rawSize = read32() else { return nil } guard let rawSize = read32() else { return nil }
@@ -379,9 +372,11 @@ struct BinaryProtocol {
guard let rawSize = read16() else { return nil } guard let rawSize = read16() else { return nil }
originalSize = Int(rawSize) originalSize = Int(rawSize)
} }
remainingPayloadBytes -= lengthFieldBytes
guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil } guard originalSize >= 0 && originalSize <= FileTransferLimits.maxFramedFileBytes else { return nil }
let compressedSize = payloadLength - lengthFieldBytes let compressedSize = remainingPayloadBytes
guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil } guard compressedSize > 0, let compressed = readData(compressedSize) else { return nil }
remainingPayloadBytes = 0
let compressionRatio = Double(originalSize) / Double(compressedSize) let compressionRatio = Double(originalSize) / Double(compressedSize)
guard compressionRatio <= 50_000.0 else { guard compressionRatio <= 50_000.0 else {
@@ -393,7 +388,9 @@ struct BinaryProtocol {
decompressed.count == originalSize else { return nil } decompressed.count == originalSize else { return nil }
payload = decompressed payload = decompressed
} else { } else {
guard let rawPayload = readData(payloadLength) else { return nil } guard remainingPayloadBytes >= 0,
let rawPayload = readData(remainingPayloadBytes) else { return nil }
remainingPayloadBytes = 0
payload = rawPayload payload = rawPayload
} }
@@ -414,8 +411,7 @@ struct BinaryProtocol {
signature: signature, signature: signature,
ttl: ttl, ttl: ttl,
version: version, version: version,
route: route, route: route
isRSR: isRSR
) )
} }
} }
-1
View File
@@ -60,7 +60,6 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
// MARK: - Message Types // MARK: - Message Types
File diff suppressed because it is too large Load Diff
-17
View File
@@ -191,22 +191,5 @@ enum MimeType: CaseIterable, Hashable {
extension MimeType { extension MimeType {
enum Category: String { enum Category: String {
case audio, image, file case audio, image, file
/// Ends with a space
var messagePrefix: String {
switch self {
case .audio: "[voice] "
case .image: "[image] "
case .file: "[file] "
}
}
var mediaDir: String {
switch self {
case .audio: "voicenotes"
case .image: "images"
case .file: "files"
}
}
} }
} }
+15 -5
View File
@@ -7,7 +7,6 @@
// //
import Foundation import Foundation
import BitFoundation
/// Result of command processing /// Result of command processing
enum CommandResult { enum CommandResult {
@@ -60,12 +59,23 @@ final class CommandProcessor {
weak var meshService: Transport? weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol private let identityManager: SecureIdentityStateManagerProtocol
/// Backward-compatible property for existing code
weak var chatViewModel: CommandContextProvider? {
get { contextProvider }
set { contextProvider = newValue }
}
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) { init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.contextProvider = contextProvider self.contextProvider = contextProvider
self.meshService = meshService self.meshService = meshService
self.identityManager = identityManager self.identityManager = identityManager
} }
/// Backward-compatible initializer
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
}
/// Process a command string /// Process a command string
@MainActor @MainActor
func process(_ command: String) -> CommandResult { func process(_ command: String) -> CommandResult {
@@ -167,7 +177,7 @@ final class CommandProcessor {
} }
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmed let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(command) <nickname>") return .error(message: "usage: /\(command) <nickname>")
} }
@@ -210,7 +220,7 @@ final class CommandProcessor {
} }
private func handleBlock(_ args: String) -> CommandResult { private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmed let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty { if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks // List blocked users (mesh) and geohash (Nostr) blocks
@@ -285,7 +295,7 @@ final class CommandProcessor {
} }
private func handleUnblock(_ args: String) -> CommandResult { private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmed let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>") return .error(message: "usage: /unblock <nickname>")
} }
@@ -312,7 +322,7 @@ final class CommandProcessor {
} }
private func handleFavorite(_ args: String, add: Bool) -> CommandResult { private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmed let targetName = args.trimmingCharacters(in: .whitespaces)
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>") return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
} }
@@ -1,5 +1,4 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
@@ -32,6 +31,9 @@ final class FavoritesPersistenceService: ObservableObject {
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship @Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = [] @Published private(set) var mutualFavorites: Set<Data> = []
private let userDefaults = UserDefaults.standard
private var cancellables = Set<AnyCancellable>()
static let shared = FavoritesPersistenceService() static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager()) { init(keychain: KeychainManagerProtocol = KeychainManager()) {
@@ -84,9 +84,6 @@ public final class GeohashParticipantTracker: ObservableObject {
map[key] = Date() map[key] = Date()
participants[geohash] = map participants[geohash] = map
// Always notify observers that state has changed so counts in UI update
objectWillChange.send()
// Only refresh visible list if this geohash is currently active // Only refresh visible list if this geohash is currently active
if activeGeohash == geohash { if activeGeohash == geohash {
refresh() refresh()
@@ -1,264 +0,0 @@
//
// GeohashPresenceService.swift
// bitchat
//
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
// to geohash location channels.
//
// This is free and unencumbered software released into the public domain.
//
import Foundation
import Combine
import BitLogger
import Tor
protocol GeohashPresenceTimerProtocol: AnyObject {
var isValid: Bool { get }
func invalidate()
}
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
private let base: Timer
init(base: Timer) {
self.base = base
}
var isValid: Bool { base.isValid }
func invalidate() {
base.invalidate()
}
}
/// Service that coordinates the broadcasting of presence heartbeats.
///
/// Behavior:
/// - Monitors location changes via LocationStateManager
/// - Broadcasts Kind 20001 events to low-precision geohash channels
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
@MainActor
final class GeohashPresenceService: ObservableObject {
static let shared = GeohashPresenceService()
private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: GeohashPresenceTimerProtocol?
private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
private let torIsReady: () -> Bool
private let torIsForeground: () -> Bool
private let deriveIdentity: (String) throws -> NostrIdentity
private let relayLookup: (String, Int) -> [String]
private let relaySender: (NostrEvent, [String]) -> Void
private let sleeper: (UInt64) async -> Void
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
// MARK: - Constants
// Loop interval range in seconds
private let loopMinInterval: TimeInterval
private let loopMaxInterval: TimeInterval
// Per-broadcast decorrelation delay range in seconds
private let burstMinDelay: TimeInterval
private let burstMaxDelay: TimeInterval
// Privacy: Only broadcast to these levels
private let allowedPrecisions: Set<Int> = [
GeohashChannelLevel.region.precision, // 2
GeohashChannelLevel.province.precision, // 4
GeohashChannelLevel.city.precision // 5
]
private init() {
let idBridge = NostrIdentityBridge()
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.map { _ in () }
.eraseToAnyPublisher()
self.torIsReady = { TorManager.shared.isReady }
self.torIsForeground = { TorManager.shared.isForeground() }
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
self.relayLookup = { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
}
self.relaySender = { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
}
self.sleeper = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
}
self.scheduleTimer = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
}
self.loopMinInterval = 40.0
self.loopMaxInterval = 80.0
self.burstMinDelay = 2.0
self.burstMaxDelay = 5.0
setupObservers()
}
internal init(
availableChannelsProvider: @escaping () -> [GeohashChannel],
locationChanges: AnyPublisher<[GeohashChannel], Never>,
torReadyPublisher: AnyPublisher<Void, Never>,
torIsReady: @escaping () -> Bool,
torIsForeground: @escaping () -> Bool,
deriveIdentity: @escaping (String) throws -> NostrIdentity,
relayLookup: @escaping (String, Int) -> [String],
relaySender: @escaping (NostrEvent, [String]) -> Void,
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
},
loopMinInterval: TimeInterval = 40.0,
loopMaxInterval: TimeInterval = 80.0,
burstMinDelay: TimeInterval = 2.0,
burstMaxDelay: TimeInterval = 5.0
) {
self.availableChannelsProvider = availableChannelsProvider
self.locationChanges = locationChanges
self.torReadyPublisher = torReadyPublisher
self.torIsReady = torIsReady
self.torIsForeground = torIsForeground
self.deriveIdentity = deriveIdentity
self.relayLookup = relayLookup
self.relaySender = relaySender
self.sleeper = sleeper
self.scheduleTimer = scheduleTimer
self.loopMinInterval = loopMinInterval
self.loopMaxInterval = loopMaxInterval
self.burstMinDelay = burstMinDelay
self.burstMaxDelay = burstMaxDelay
setupObservers()
}
/// Start the service (safe to call multiple times)
func start() {
SecureLogger.info("Presence: service starting...", category: .session)
scheduleNextHeartbeat()
}
private func setupObservers() {
// Monitor location channel changes
locationChanges
.dropFirst()
.sink { [weak self] _ in
self?.handleLocationChange()
}
.store(in: &subscriptions)
// Monitor Tor readiness to kick off heartbeat if it was stalled
torReadyPublisher
.sink { [weak self] _ in
self?.handleConnectivityChange()
}
.store(in: &subscriptions)
}
func handleLocationChange() {
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate()
// Small delay to allow location state to settle
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func handleConnectivityChange() {
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
scheduleNextHeartbeat()
}
}
func scheduleNextHeartbeat() {
heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in
self?.performHeartbeat()
}
}
}
func performHeartbeat() {
// Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() }
// 1. Check preconditions
guard torIsReady() else {
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
return
}
// App must be active (or at least we shouldn't broadcast if in background, usually)
if !torIsForeground() {
return
}
// 2. Get channels
let channels = availableChannelsProvider()
guard !channels.isEmpty else { return }
// 3. Filter and broadcast
// We use Task + sleep for decorrelation to allow the main runloop to proceed
for channel in channels {
// Check privacy restriction
if !self.allowedPrecisions.contains(channel.geohash.count) {
continue
}
// Launch independent task for each channel's delay
Task { @MainActor in
// Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000)
await self.sleeper(nanoseconds)
self.broadcastPresence(for: channel.geohash)
}
}
}
func broadcastPresence(for geohash: String) {
do {
guard let identity = try? deriveIdentity(geohash) else {
return
}
let event = try NostrProtocol.createGeohashPresenceEvent(
geohash: geohash,
senderIdentity: identity
)
// Send via RelayManager
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
if !targetRelays.isEmpty {
relaySender(event, targetRelays)
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
}
} catch {
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
}
}
}
-221
View File
@@ -10,47 +10,6 @@ import BitLogger
import Foundation import Foundation
import Security import Security
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
protocol KeychainManagerProtocol { protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data? func getIdentityKey(forKey key: String) -> Data?
@@ -62,12 +21,6 @@ protocol KeychainManagerProtocol {
func verifyIdentityKeyExists() -> Bool func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper) // MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name /// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?) func save(key: String, data: Data, service: String, accessible: CFString?)
@@ -102,180 +55,6 @@ final class KeychainManager: KeychainManagerProtocol {
return result return result
} }
// MARK: - BCH-01-009: Methods with Proper Error Classification
/// Get identity key with detailed result for proper error handling
/// Distinguishes between missing keys (expected) and critical failures
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
let fullKey = "identity_\(key)"
return retrieveDataWithResult(forKey: fullKey)
}
/// Save identity key with detailed result and retry logic for transient errors
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
let fullKey = "identity_\(key)"
return saveDataWithResult(keyData, forKey: fullKey)
}
/// Internal method to save data with detailed result and retry for transient errors
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
// Delete any existing item first to ensure clean state
_ = delete(forKey: key)
// Build base query
var base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecValueData as String: data,
kSecAttrService as String: service,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
kSecAttrLabel as String: "bitchat-\(key)"
]
#if os(macOS)
base[kSecAttrSynchronizable as String] = false
#endif
func attempt(addAccessGroup: Bool) -> OSStatus {
var query = base
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
return SecItemAdd(query as CFDictionary, nil)
}
#if os(iOS)
var status = attempt(addAccessGroup: true)
if status == -34018 { // Missing entitlement, retry without access group
status = attempt(addAccessGroup: false)
}
#else
let status = attempt(addAccessGroup: false)
#endif
// Classify the result
let result = classifySaveStatus(status)
// Log all outcomes consistently
switch result {
case .success:
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
case .duplicateItem:
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
case .storageFull:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain storage full for key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain save failed for key: \(key)", category: .keychain)
}
// Retry transient errors with exponential backoff
if result.isRecoverableError && retryCount > 0 {
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
usleep(delayMs * 1000)
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
}
return result
}
/// Internal method to retrieve data with detailed result
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: key,
kSecAttrService as String: service,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
func attempt(withAccessGroup: Bool) -> OSStatus {
var q = base
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
return SecItemCopyMatching(q as CFDictionary, &result)
}
#if os(iOS)
var status = attempt(withAccessGroup: true)
if status == -34018 { status = attempt(withAccessGroup: false) }
#else
let status = attempt(withAccessGroup: false)
#endif
// Classify the result
let readResult = classifyReadStatus(status, data: result as? Data)
// Log all outcomes consistently
switch readResult {
case .success:
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
case .itemNotFound:
// Expected case - no logging needed for missing keys
break
case .accessDenied:
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Keychain access denied for key: \(key)", category: .keychain)
case .deviceLocked:
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
case .authenticationFailed:
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
case .otherError(let code):
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
context: "Keychain read failed for key: \(key)", category: .keychain)
}
return readResult
}
/// Classify keychain read status into meaningful categories
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
switch status {
case errSecSuccess:
if let data = data {
return .success(data)
}
return .otherError(status)
case errSecItemNotFound:
return .itemNotFound
case errSecInteractionNotAllowed:
// Device is locked or in a state that doesn't allow keychain access
return .deviceLocked
case errSecAuthFailed:
return .authenticationFailed
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
default:
return .otherError(status)
}
}
/// Classify keychain save status into meaningful categories
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
switch status {
case errSecSuccess:
return .success
case errSecDuplicateItem:
return .duplicateItem
case errSecInteractionNotAllowed:
return .deviceLocked
case -34018: // errSecMissingEntitlement
return .accessDenied
case errSecNotAvailable:
return .accessDenied
case errSecDiskFull:
return .storageFull
default:
return .otherError(status)
}
}
// MARK: - Generic Operations // MARK: - Generic Operations
private func save(_ value: String, forKey key: String) -> Bool { private func save(_ value: String, forKey key: String) -> Bool {
+3 -2
View File
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
var displayName: String { var displayName: String {
let suffix = String(pubkey.suffix(4)) let suffix = String(pubkey.suffix(4))
if let nick = nickname?.trimmedOrNilIfEmpty { if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "\(nick)#\(suffix)" return "\(nick)#\(suffix)"
} }
return "anon#\(suffix)" return "anon#\(suffix)"
@@ -199,7 +199,8 @@ final class LocationNotesManager: ObservableObject {
/// Send a location note for the current geohash using the per-geohash identity. /// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) { func send(content: String, nickname: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { guard !relays.isEmpty else {
state = .noRelays state = .noRelays
+15 -100
View File
@@ -5,79 +5,6 @@ import Combine
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS)
import CoreLocation import CoreLocation
protocol LocationStateManaging: AnyObject {
var delegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var distanceFilter: CLLocationDistance { get set }
var authorizationStatus: CLAuthorizationStatus { get }
func requestWhenInUseAuthorization()
func requestLocation()
func startUpdatingLocation()
func stopUpdatingLocation()
}
protocol LocationStateGeocoding: AnyObject {
func cancelGeocode()
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
)
}
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
private let base = CLLocationManager()
var delegate: CLLocationManagerDelegate? {
get { base.delegate }
set { base.delegate = newValue }
}
var desiredAccuracy: CLLocationAccuracy {
get { base.desiredAccuracy }
set { base.desiredAccuracy = newValue }
}
var distanceFilter: CLLocationDistance {
get { base.distanceFilter }
set { base.distanceFilter = newValue }
}
var authorizationStatus: CLAuthorizationStatus {
base.authorizationStatus
}
func requestWhenInUseAuthorization() {
base.requestWhenInUseAuthorization()
}
func requestLocation() {
base.requestLocation()
}
func startUpdatingLocation() {
base.startUpdatingLocation()
}
func stopUpdatingLocation() {
base.stopUpdatingLocation()
}
}
private final class CLGeocoderAdapter: LocationStateGeocoding {
private let base = CLGeocoder()
func cancelGeocode() {
base.cancelGeocode()
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
}
}
/// Unified manager for location-based channel state including: /// Unified manager for location-based channel state including:
/// - CoreLocation permissions and one-shot location retrieval /// - CoreLocation permissions and one-shot location retrieval
/// - Geohash channel computation from coordinates /// - Geohash channel computation from coordinates
@@ -99,8 +26,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Properties (CoreLocation) // MARK: - Private Properties (CoreLocation)
private let cl: LocationStateManaging private let cl = CLLocationManager()
private let geocoder: LocationStateGeocoding private let geocoder = CLGeocoder()
private var lastLocation: CLLocation? private var lastLocation: CLLocation?
private var refreshTimer: Timer? private var refreshTimer: Timer?
private var isGeocoding: Bool = false private var isGeocoding: Bool = false
@@ -146,8 +73,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private override init() { private override init() {
self.storage = .standard self.storage = .standard
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init() super.init()
// Skip CoreLocation setup in test environments // Skip CoreLocation setup in test environments
@@ -167,30 +92,10 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
/// Internal initializer for testing with custom storage /// Internal initializer for testing with custom storage
init(storage: UserDefaults) { init(storage: UserDefaults) {
self.storage = storage self.storage = storage
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init() super.init()
loadPersistedState() loadPersistedState()
} }
internal init(
storage: UserDefaults,
locationManager: LocationStateManaging,
geocoder: LocationStateGeocoding,
shouldInitializeCoreLocation: Bool
) {
self.storage = storage
self.cl = locationManager
self.geocoder = geocoder
super.init()
loadPersistedState()
guard shouldInitializeCoreLocation else { return }
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
initializePermissionState()
}
private func loadPersistedState() { private func loadPersistedState() {
// Load selected channel // Load selected channel
if let data = storage.data(forKey: selectedChannelKey), if let data = storage.data(forKey: selectedChannelKey),
@@ -227,7 +132,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
private func initializePermissionState() { private func initializePermissionState() {
let status = cl.authorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status) updatePermissionState(from: status)
// Fall back to persisted teleport state if no location authorization // Fall back to persisted teleport state if no location authorization
@@ -246,7 +156,12 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Public API (Permissions & Location) // MARK: - Public API (Permissions & Location)
func enableLocationChannels() { func enableLocationChannels() {
let status = cl.authorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
switch status { switch status {
case .notDetermined: case .notDetermined:
cl.requestWhenInUseAuthorization() cl.requestWhenInUseAuthorization()
@@ -597,7 +512,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func normalizeGeohash(_ s: String) -> String { private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s return s
.trimmed .trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() .lowercased()
.replacingOccurrences(of: "#", with: "") .replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) } .filter { allowed.contains($0) }
+72 -82
View File
@@ -6,114 +6,104 @@ final class MeshTopologyTracker {
private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent) private let queue = DispatchQueue(label: "mesh.topology", attributes: .concurrent)
private let hopSize = 8 private let hopSize = 8
// Directed claims: Key claims to see Value (neighbors) private var adjacency: [RoutingID: Set<RoutingID>] = [:]
private var claims: [RoutingID: Set<RoutingID>] = [:]
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
// Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
func reset() { func reset() {
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims.removeAll() self.adjacency.removeAll()
self.lastSeen.removeAll()
} }
} }
/// Update the topology with a node's self-reported neighbor list func recordDirectLink(between a: Data?, and b: Data?) {
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) { guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
guard let source = sanitize(sourceData) else { return }
// Sanitize neighbors and exclude self-loops
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims[source] = validNeighbors var setA = self.adjacency[left] ?? []
self.lastSeen[source] = Date() setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
func removeDirectLink(between a: Data?, and b: Data?) {
guard let left = sanitize(a), let right = sanitize(b), left != right else { return }
queue.sync(flags: .barrier) {
if var setA = self.adjacency[left] {
setA.remove(right)
self.adjacency[left] = setA.isEmpty ? nil : setA
}
if var setB = self.adjacency[right] {
setB.remove(left)
self.adjacency[right] = setB.isEmpty ? nil : setB
}
} }
} }
func removePeer(_ data: Data?) { func removePeer(_ data: Data?) {
guard let peer = sanitize(data) else { return } guard let peer = sanitize(data) else { return }
queue.sync(flags: .barrier) { queue.sync(flags: .barrier) {
self.claims.removeValue(forKey: peer) guard let neighbors = self.adjacency.removeValue(forKey: peer) else { return }
self.lastSeen.removeValue(forKey: peer) for neighbor in neighbors {
} if var set = self.adjacency[neighbor] {
} set.remove(peer)
self.adjacency[neighbor] = set.isEmpty ? nil : set
/// Prune nodes that haven't updated their topology in `age` seconds }
func prune(olderThan age: TimeInterval) {
let deadline = Date().addingTimeInterval(-age)
queue.sync(flags: .barrier) {
let stale = self.lastSeen.filter { $0.value < deadline }
for (peer, _) in stale {
self.claims.removeValue(forKey: peer)
self.lastSeen.removeValue(forKey: peer)
} }
} }
} }
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? { func recordRoute(_ hops: [Data]) {
let sanitized = hops.compactMap { sanitize($0) }
guard sanitized.count >= 2 else { return }
queue.sync(flags: .barrier) {
for idx in 0..<(sanitized.count - 1) {
let left = sanitized[idx]
let right = sanitized[idx + 1]
guard left != right else { continue }
var setA = self.adjacency[left] ?? []
setA.insert(right)
self.adjacency[left] = setA
var setB = self.adjacency[right] ?? []
setB.insert(left)
self.adjacency[right] = setB
}
}
}
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 255) -> [Data]? {
guard let source = sanitize(start), let target = sanitize(goal) else { return nil } guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
if source == target { return [] } // Direct connection, no intermediate hops if source == target { return [source] }
return queue.sync { let graph = queue.sync { adjacency }
let now = Date() guard graph[source] != nil, graph[target] != nil else { return nil }
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
// BFS var visited: Set<RoutingID> = [source]
var visited: Set<RoutingID> = [source] var queuePaths: [[RoutingID]] = [[source]]
// Queue stores paths: [Start, Hop1, Hop2, ..., Current] var index = 0
var queuePaths: [[RoutingID]] = [[source]]
while !queuePaths.isEmpty { while index < queuePaths.count {
let path = queuePaths.removeFirst() let path = queuePaths[index]
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate index += 1
// If maxHops = 10, max edges = 11, max nodes = 12. guard path.count <= maxHops else { continue }
if path.count > maxHops + 1 { continue } guard let last = path.last, let neighbors = graph[last] else { continue }
guard let last = path.last else { continue } for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// Get neighbors that 'last' claims to see var nextPath = path
guard let neighbors = claims[last] else { continue } nextPath.append(neighbor)
if neighbor == target { return nextPath }
// Check if 'last' node's topology info is fresh if nextPath.count <= maxHops {
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
continue // Skip stale nodes
}
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'?
guard let neighborClaims = claims[neighbor],
neighborClaims.contains(last) else {
continue
}
// Check if 'neighbor' node's topology info is fresh
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
continue // Skip edges to stale nodes
}
var nextPath = path
nextPath.append(neighbor)
if neighbor == target {
// Return only intermediate hops
// Path: [Source, I1, I2, Target] -> [I1, I2]
return Array(nextPath.dropFirst().dropLast())
}
visited.insert(neighbor)
queuePaths.append(nextPath) queuePaths.append(nextPath)
} }
visited.insert(neighbor)
} }
return nil
} }
return nil
} }
// MARK: - Helpers // MARK: - Helpers
@@ -145,7 +145,7 @@ enum ContentNormalizer {
} }
// Trim and collapse whitespace // Trim and collapse whitespace
let trimmed = simplified.trimmed let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash // Take prefix and hash
@@ -6,7 +6,6 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
+29 -70
View File
@@ -1,25 +1,11 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
/// 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 var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
init(transports: [Transport]) { init(transports: [Transport]) {
self.transports = transports self.transports = transports
@@ -48,60 +34,52 @@ final class MessageRouter {
} }
} }
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerReachable(peerID) }
}
private func connectedTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerConnected(peerID) }
}
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = reachableTransport(for: peerID) { // Try to find a reachable transport
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else { } else {
// Queue for later with timestamp for TTL tracking // Queue for later
if outbox[peerID] == nil { outbox[peerID] = [] } if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date()) SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))", category: .session)
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", 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)
} }
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) { if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID) transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty { } else if !transports.isEmpty {
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
// Or better: just try the first one that supports it?
// Existing logic preferred mesh, then nostr.
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
// But let's stick to the reachable check.
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session) SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
} }
} }
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) { func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) { if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID) transport.sendDeliveryAck(for: messageID, to: peerID)
} }
} }
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = connectedTransport(for: peerID) { if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = reachableTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
// Fallback: try all? or just the last one?
// Old logic: if mesh connected, mesh. Else nostr.
// Note: NostrTransport.isPeerReachable now returns true if mapped.
// If not mapped, we can't send via Nostr anyway.
} }
} }
@@ -110,22 +88,14 @@ final class MessageRouter {
func flushOutbox(for peerID: PeerID) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
let now = Date() for (content, nickname, messageID) in queued {
var remaining: [QueuedMessage] = [] if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
for message in queued { transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
continue
}
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
} else { } else {
remaining.append(message) remaining.append((content, nickname, messageID))
} }
} }
@@ -139,15 +109,4 @@ final class MessageRouter {
func flushAllOutbox() { func flushAllOutbox() {
for key in Array(outbox.keys) { flushOutbox(for: key) } for key in Array(outbox.keys) { flushOutbox(for: key) }
} }
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = Date()
for peerID in Array(outbox.keys) {
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
}
}
} }
+18 -80
View File
@@ -3,27 +3,6 @@ import BitLogger
import Combine import Combine
import Tor import Tor
@MainActor
protocol NetworkActivationTorControlling: AnyObject {
func setAutoStartAllowed(_ allowed: Bool)
func startIfNeeded()
func shutdownCompletely()
}
@MainActor
protocol NetworkActivationRelayControlling: AnyObject {
func connect()
func disconnect()
}
protocol NetworkActivationProxyControlling: AnyObject {
func setProxyMode(useTor: Bool)
}
extension TorManager: NetworkActivationTorControlling {}
extension NostrRelayManager: NetworkActivationRelayControlling {}
extension TorURLSession: NetworkActivationProxyControlling {}
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR /// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start. /// there exists at least one mutual favorite. Otherwise, do not start.
@@ -38,55 +17,14 @@ final class NetworkActivationService: ObservableObject {
private var started = false private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled" private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false private var torAutoStartDesired: Bool = false
private let storage: UserDefaults
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
private init() { private init() {}
storage = .standard
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
internal init(
storage: UserDefaults,
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>,
torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling,
notificationCenter: NotificationCenter = .default
) {
self.storage = storage
self.locationPermissionPublisher = locationPermissionPublisher
self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
func start() { func start() {
guard !started else { return } guard !started else { return }
started = true started = true
if let stored = storage.object(forKey: torPreferenceKey) as? Bool { if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored userTorEnabled = stored
} else { } else {
userTorEnabled = true userTorEnabled = true
@@ -96,16 +34,16 @@ final class NetworkActivationService: ObservableObject {
let allowed = basePolicyAllowed() let allowed = basePolicyAllowed()
activationAllowed = allowed activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled torAutoStartDesired = allowed && userTorEnabled
torController.setAutoStartAllowed(torAutoStartDesired) TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired) applyTorState(torDesired: torAutoStartDesired)
if allowed { if allowed {
relayController.connect() NostrRelayManager.shared.connect()
} else { } else {
relayController.disconnect() NostrRelayManager.shared.disconnect()
} }
// React to location permission changes // React to location permission changes
locationPermissionPublisher LocationChannelManager.shared.$permissionState
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.reevaluate() self?.reevaluate()
@@ -113,7 +51,7 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
// React to mutual favorites changes // React to mutual favorites changes
mutualFavoritesPublisher FavoritesPersistenceService.shared.$mutualFavorites
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.reevaluate() self?.reevaluate()
@@ -124,8 +62,8 @@ final class NetworkActivationService: ObservableObject {
func setUserTorEnabled(_ enabled: Bool) { func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return } guard enabled != userTorEnabled else { return }
userTorEnabled = enabled userTorEnabled = enabled
storage.set(enabled, forKey: torPreferenceKey) UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
notificationCenter.post( NotificationCenter.default.post(
name: .TorUserPreferenceChanged, name: .TorUserPreferenceChanged,
object: nil, object: nil,
userInfo: ["enabled": enabled] userInfo: ["enabled": enabled]
@@ -144,33 +82,33 @@ final class NetworkActivationService: ObservableObject {
} }
if statusChanged || torChanged { if statusChanged || torChanged {
torAutoStartDesired = torDesired torAutoStartDesired = torDesired
torController.setAutoStartAllowed(torDesired) TorManager.shared.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired) applyTorState(torDesired: torDesired)
} }
if allowed { if allowed {
if torChanged { if torChanged {
// Reset relay sockets when switching transport path (Tor direct) // Reset relay sockets when switching transport path (Tor direct)
relayController.disconnect() NostrRelayManager.shared.disconnect()
} }
relayController.connect() NostrRelayManager.shared.connect()
} else if statusChanged { } else if statusChanged {
relayController.disconnect() NostrRelayManager.shared.disconnect()
} }
} }
private func basePolicyAllowed() -> Bool { private func basePolicyAllowed() -> Bool {
let permOK = permissionProvider() == .authorized let permOK = LocationChannelManager.shared.permissionState == .authorized
let hasMutual = !mutualFavoritesProvider().isEmpty let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
private func applyTorState(torDesired: Bool) { private func applyTorState(torDesired: Bool) {
proxyController.setProxyMode(useTor: torDesired) TorURLSession.shared.setProxyMode(useTor: torDesired)
if torDesired { if torDesired {
torController.startIfNeeded() TorManager.shared.startIfNeeded()
} else { } else {
torController.shutdownCompletely() TorManager.shared.shutdownCompletely()
} }
} }
} }
+26 -127
View File
@@ -83,7 +83,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -201,85 +200,46 @@ final class NoiseEncryptionService {
init(keychain: KeychainManagerProtocol) { init(keychain: KeychainManagerProtocol) {
self.keychain = keychain self.keychain = keychain
// BCH-01-009: Load or create static identity key with proper error handling // Load or create static identity key (ONLY from keychain)
let loadedKey: Curve25519.KeyAgreement.PrivateKey let loadedKey: Curve25519.KeyAgreement.PrivateKey
// Try to load from keychain with proper error classification // Try to load from keychain
let noiseKeyResult = keychain.getIdentityKeyWithResult(forKey: "noiseStaticKey") if let identityData = keychain.getIdentityKey(forKey: "noiseStaticKey"),
let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) {
switch noiseKeyResult { loadedKey = key
case .success(let identityData): SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true)
if let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: identityData) { }
loadedKey = key // If no identity exists, create new one
SecureLogger.logKeyOperation(.load, keyType: "noiseStaticKey", success: true) else {
} else {
// Data corrupted, regenerate
SecureLogger.warning("Noise static key data corrupted, regenerating", category: .keychain)
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedKey = Self.generateAndSaveNoiseKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key (will be lost on restart)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral identity", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey() loadedKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = loadedKey.rawRepresentation
case .deviceLocked, .authenticationFailed: // Save to keychain
// Recoverable error - use ephemeral key and warn let saved = keychain.saveIdentityKey(keyData, forKey: "noiseStaticKey")
SecureLogger.warning("Device locked or auth failed - using ephemeral identity until unlocked", category: .keychain) SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: saved)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral identity", category: .keychain)
loadedKey = Curve25519.KeyAgreement.PrivateKey()
} }
// Now assign the final value // Now assign the final value
self.staticIdentityKey = loadedKey self.staticIdentityKey = loadedKey
self.staticIdentityPublicKey = staticIdentityKey.publicKey self.staticIdentityPublicKey = staticIdentityKey.publicKey
// BCH-01-009: Load or create signing key pair with proper error handling // Load or create signing key pair
let loadedSigningKey: Curve25519.Signing.PrivateKey let loadedSigningKey: Curve25519.Signing.PrivateKey
let signingKeyResult = keychain.getIdentityKeyWithResult(forKey: "ed25519SigningKey") // Try to load from keychain
if let signingData = keychain.getIdentityKey(forKey: "ed25519SigningKey"),
switch signingKeyResult { let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) {
case .success(let signingData): loadedSigningKey = key
if let key = try? Curve25519.Signing.PrivateKey(rawRepresentation: signingData) { SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true)
loadedSigningKey = key }
SecureLogger.logKeyOperation(.load, keyType: "ed25519SigningKey", success: true) // If no signing key exists, create new one
} else { else {
// Data corrupted, regenerate
SecureLogger.warning("Ed25519 signing key data corrupted, regenerating", category: .keychain)
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
}
case .itemNotFound:
// Expected case: no key exists yet, create new one
loadedSigningKey = Self.generateAndSaveSigningKey(keychain: keychain)
case .accessDenied:
// Critical error - log but proceed with ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Keychain access denied - using ephemeral signing key", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey() loadedSigningKey = Curve25519.Signing.PrivateKey()
let keyData = loadedSigningKey.rawRepresentation
case .deviceLocked, .authenticationFailed: // Save to keychain
// Recoverable error - use ephemeral key and warn let saved = keychain.saveIdentityKey(keyData, forKey: "ed25519SigningKey")
SecureLogger.warning("Device locked or auth failed - using ephemeral signing key until unlocked", category: .keychain) SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: saved)
loadedSigningKey = Curve25519.Signing.PrivateKey()
case .otherError(let status):
// Unexpected error - log and use ephemeral key
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
context: "Unexpected keychain error - using ephemeral signing key", category: .keychain)
loadedSigningKey = Curve25519.Signing.PrivateKey()
} }
// Now assign the signing keys // Now assign the signing keys
@@ -298,56 +258,6 @@ final class NoiseEncryptionService {
startRekeyTimer() startRekeyTimer()
} }
// MARK: - BCH-01-009: Key Generation Helpers with Save Verification
/// Generate and save a new Noise static key, verifying the save succeeds
private static func generateAndSaveNoiseKey(keychain: KeychainManagerProtocol) -> Curve25519.KeyAgreement.PrivateKey {
let newKey = Curve25519.KeyAgreement.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "noiseStaticKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "noiseStaticKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Noise key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist noise static key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
/// Generate and save a new Ed25519 signing key, verifying the save succeeds
private static func generateAndSaveSigningKey(keychain: KeychainManagerProtocol) -> Curve25519.Signing.PrivateKey {
let newKey = Curve25519.Signing.PrivateKey()
let keyData = newKey.rawRepresentation
// Save to keychain and verify success
let saveResult = keychain.saveIdentityKeyWithResult(keyData, forKey: "ed25519SigningKey")
switch saveResult {
case .success:
SecureLogger.logKeyOperation(.create, keyType: "ed25519SigningKey", success: true)
case .duplicateItem:
// This shouldn't happen since we just tried to load, but handle it
SecureLogger.warning("Signing key already exists (race condition?)", category: .keychain)
default:
// Save failed - log but continue with the key (it will be ephemeral)
SecureLogger.error(NSError(domain: "Keychain", code: -1),
context: "Failed to persist signing key - identity will be lost on restart",
category: .keychain)
}
return newKey
}
// MARK: - Public Interface // MARK: - Public Interface
/// Get our static public key for sharing /// Get our static public key for sharing
@@ -618,17 +528,6 @@ final class NoiseEncryptionService {
rateLimiter.resetAll() rateLimiter.resetAll()
} }
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
func clearSession(for peerID: PeerID) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
}
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
}
// MARK: - Private Helpers // MARK: - Private Helpers
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) { private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
+105 -101
View File
@@ -1,36 +1,9 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
// Minimal Nostr transport conforming to Transport for offline sending // Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport, @unchecked Sendable { final class NostrTransport: Transport, @unchecked Sendable {
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies(
notificationCenter: .default,
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
}
}
// Provide BLE short peer ID for BitChat embedding // Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "") var senderPeerID = PeerID(str: "")
@@ -44,27 +17,20 @@ final class NostrTransport: Transport, @unchecked Sendable {
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe) // Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = [] private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor @MainActor
init( init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge self.idBridge = idBridge
self.dependencies = dependencies ?? .live(idBridge: idBridge)
setupObservers() setupObservers()
// Synchronously warm the cache to avoid startup race // Synchronously warm the cache to avoid startup race
let favorites = self.dependencies.loadFavorites() let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil } .filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) } .map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -74,14 +40,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
} }
deinit {
if let favoriteStatusObserver {
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
}
}
private func setupObservers() { private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver( NotificationCenter.default.addObserver(
forName: .favoriteStatusChanged, forName: .favoriteStatusChanged,
object: nil, object: nil,
queue: nil queue: nil
@@ -92,7 +52,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
private func refreshReachablePeers() { private func refreshReachablePeers() {
Task { @MainActor in Task { @MainActor in
let favorites = dependencies.loadFavorites() let favorites = FavoritesPersistenceService.shared.favorites
let reachable = favorites.values let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil } .filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) } .map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -158,15 +118,32 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
let recipientHex = npubToHex(recipientNpub), guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session) // Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else {
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return
}
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return return
} }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -181,31 +158,58 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) { func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
let recipientHex = npubToHex(recipientNpub), guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let senderIdentity = try? dependencies.currentIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session) SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return return
} }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity) guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
} }
} }
func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) { func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
let recipientHex = npubToHex(recipientNpub), guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let senderIdentity = try? dependencies.currentIdentity() else { return } SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session) let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
return
}
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return return
} }
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
} }
} }
} }
@@ -217,17 +221,21 @@ extension NostrTransport {
// MARK: Geohash ACK helpers // MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session) SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
} }
} }
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in Task { @MainActor in
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session) SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
} }
} }
@@ -235,12 +243,19 @@ extension NostrTransport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard !recipientHex.isEmpty else { return } guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session) SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return return
} }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
} }
} }
} }
@@ -248,32 +263,6 @@ extension NostrTransport {
// MARK: - Private Helpers // MARK: - Private Helpers
extension NostrTransport { extension NostrTransport {
/// Converts npub bech32 string to hex pubkey
@MainActor
private func npubToHex(_ npub: String) -> String? {
do {
let (hrp, data) = try Bech32.decode(npub)
guard hrp == "npub" else { return nil }
return data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return nil
}
}
/// Creates and sends a gift-wrapped private message event
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
}
dependencies.sendEvent(event)
}
/// Must be called within a barrier on `queue` /// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() { private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return } guard !isSendingReadAcks else { return }
@@ -286,21 +275,36 @@ extension NostrTransport {
/// Sends a single read ack item (called after extraction from queue within barrier) /// Sends a single read ack item (called after extraction from queue within barrier)
private func sendReadAckItem(_ item: QueuedRead) { private func sendReadAckItem(_ item: QueuedRead) {
Task { @MainActor in Task { @MainActor in
defer { scheduleNextReadAck() } guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let recipientNpub = resolveRecipientNpub(for: item.peerID), guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
let recipientHex = npubToHex(recipientNpub), SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let senderIdentity = try? dependencies.currentIdentity() else { return } // Convert recipient npub -> hex
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session) let recipientHex: String
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { do {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
scheduleNextReadAck()
return return
} }
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity) guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return
}
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
} }
} }
private func scheduleNextReadAck() { private func scheduleNextReadAck() {
dependencies.scheduleAfter(readAckInterval) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
self?.queue.async(flags: .barrier) { [weak self] in self?.queue.async(flags: .barrier) { [weak self] in
self?.isSendingReadAcks = false self?.isSendingReadAcks = false
self?.processReadQueueIfNeeded() self?.processReadQueueIfNeeded()
@@ -311,12 +315,12 @@ extension NostrTransport {
@MainActor @MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? { private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID.id), if let noiseKey = Data(hexString: peerID.id),
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if peerID.id.count == 16, if peerID.id.count == 16,
let fav = dependencies.favoriteStatusForPeerID(peerID), let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
+10 -91
View File
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
import UserNotifications import UserNotifications
#if os(iOS) #if os(iOS)
@@ -15,103 +14,24 @@ import UIKit
import AppKit import AppKit
#endif #endif
protocol NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
)
}
protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
center.requestAuthorization(options: options, completionHandler: completionHandler)
}
}
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func add(_ request: UNNotificationRequest) {
Task {
try? await center.add(request)
}
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(false, nil)
}
}
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
final class NotificationService { final class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI) /// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool { private var isRunningTests: Bool {
isRunningTestsProvider() let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
} }
private init() { private init() {}
self.isRunningTestsProvider = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
}
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
@@ -144,7 +64,7 @@ final class NotificationService {
trigger: nil // Deliver immediately trigger: nil // Deliver immediately
) )
requestDeliverer.add(request) UNUserNotificationCenter.current().add(request)
} }
func sendMentionNotification(from sender: String, message: String) { func sendMentionNotification(from sender: String, message: String) {
@@ -176,8 +96,7 @@ final class NotificationService {
func sendNetworkAvailableNotification(peerCount: Int) { func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
// Fixed identifier so iOS updates the existing notification instead of creating new ones let identifier = "network-available-\(Date().timeIntervalSince1970)"
let identifier = "network-available"
sendLocalNotification( sendLocalNotification(
title: title, title: title,
@@ -62,7 +62,6 @@ struct NotificationStreamAssembler {
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0 let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0 let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0 let isCompressed = (flags & BinaryProtocol.Flags.isCompressed) != 0
let hasRoute = (version >= 2) && (flags & BinaryProtocol.Flags.hasRoute) != 0
let lengthOffset = 12 let lengthOffset = 12
let payloadLength: Int let payloadLength: Int
@@ -81,15 +80,6 @@ struct NotificationStreamAssembler {
var frameLength = framePrefix + payloadLength var frameLength = framePrefix + payloadLength
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize } if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
if hasSignature { frameLength += BinaryProtocol.signatureSize } if hasSignature { frameLength += BinaryProtocol.signatureSize }
if hasRoute {
let routeCountOffset = framePrefix + (hasRecipient ? BinaryProtocol.recipientIDSize : 0)
let routeCountIndex = buffer.startIndex + routeCountOffset
guard buffer.count > routeCountOffset else { break }
let routeCount = Int(buffer[routeCountIndex])
frameLength += 1 + (routeCount * BinaryProtocol.senderIDSize)
}
if isCompressed { if isCompressed {
let rawLengthFieldBytes = (version == 2) ? 4 : 2 let rawLengthFieldBytes = (version == 2) ? 4 : 2
if payloadLength < rawLengthFieldBytes { if payloadLength < rawLengthFieldBytes {
@@ -7,7 +7,6 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
-8
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation import Foundation
import Combine import Combine
@@ -59,10 +58,6 @@ protocol Transport: AnyObject {
// 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)
// Pending file management (BCH-01-002: files held in memory until user accepts)
func acceptPendingFile(id: String) -> URL?
func declinePendingFile(id: String)
} }
extension Transport { extension Transport {
@@ -75,9 +70,6 @@ extension Transport {
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) { func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sendMessage(content, mentions: mentions) sendMessage(content, mentions: mentions)
} }
func acceptPendingFile(id: String) -> URL? { nil }
func declinePendingFile(id: String) {}
} }
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
+5 -29
View File
@@ -21,7 +21,6 @@ enum TransportConfig {
// Timers // Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect // BLE duty/announce/connect
@@ -97,12 +96,11 @@ enum TransportConfig {
// Keep scanning fully ON when we saw traffic very recently // Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0 static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05 static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 20 static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 5000 static let bleExpectedWriteMaxMs: Int = 2000
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow // Faster fragment pacing; use slightly tighter spacing for directed trains
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingMs: Int = 30 static let bleFragmentSpacingDirectedMs: Int = 4
static let bleFragmentSpacingDirectedMs: Int = 25
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0 static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0 static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0 static let bleDutyOffDurationDense: TimeInterval = 15.0
@@ -164,14 +162,6 @@ enum TransportConfig {
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4 static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15 static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.15
// BCH-01-004: Rate-limiting for subscription-triggered announces
// Prevents rapid enumeration attacks by rate-limiting announce responses
static let bleSubscriptionRateLimitMinSeconds: TimeInterval = 2.0 // Minimum interval between announces per central
static let bleSubscriptionRateLimitBackoffFactor: Double = 2.0 // Exponential backoff multiplier
static let bleSubscriptionRateLimitMaxBackoffSeconds: TimeInterval = 30.0 // Maximum backoff period
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays // Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0 static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
@@ -213,18 +203,4 @@ enum TransportConfig {
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0 static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
static let syncMaxMessageAgeSeconds: TimeInterval = 900
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
static let syncFragmentCapacity: Int = 600
static let syncFileTransferCapacity: Int = 200
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0
} }
@@ -7,7 +7,6 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
+2 -29
View File
@@ -1,6 +1,4 @@
import Foundation import Foundation
import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
@@ -8,7 +6,6 @@ final class GossipSyncManager {
func sendPacket(_ packet: BitchatPacket) func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: PeerID, packet: BitchatPacket) func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
func getConnectedPeers() -> [PeerID]
} }
private struct PacketStore { private struct PacketStore {
@@ -77,7 +74,6 @@ final class GossipSyncManager {
private let myPeerID: PeerID private let myPeerID: PeerID
private let config: Config private let config: Config
private let requestSyncManager: RequestSyncManager
weak var delegate: Delegate? weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender // Storage: broadcast packets by type, and latest announce per sender
@@ -92,10 +88,9 @@ final class GossipSyncManager {
private var lastStalePeerCleanup: Date = .distantPast private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = [] private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) { init(myPeerID: PeerID, config: Config = Config()) {
self.myPeerID = myPeerID self.myPeerID = myPeerID
self.config = config self.config = config
self.requestSyncManager = requestSyncManager
var schedules: [SyncSchedule] = [] var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 { if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast)) schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
@@ -207,19 +202,6 @@ final class GossipSyncManager {
} }
} }
private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types)
}
} else {
// Fallback to broadcast (discovery phase)
sendRequestSync(for: types)
}
}
private func sendRequestSync(for types: SyncTypeFlags) { private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types) let payload = buildGcsPayload(for: types)
let pkt = BitchatPacket( let pkt = BitchatPacket(
@@ -236,9 +218,6 @@ final class GossipSyncManager {
} }
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) { private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types) let payload = buildGcsPayload(for: types)
var recipient = Data() var recipient = Data()
var temp = peerID.id var temp = peerID.id
@@ -283,7 +262,6 @@ final class GossipSyncManager {
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
@@ -296,7 +274,6 @@ final class GossipSyncManager {
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
@@ -309,7 +286,6 @@ final class GossipSyncManager {
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
@@ -322,7 +298,6 @@ final class GossipSyncManager {
if !mightContain(idBytes) { if !mightContain(idBytes) {
var toSend = pkt var toSend = pkt
toSend.ttl = 0 toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend) delegate?.sendPacket(to: peerID, packet: toSend)
} }
} }
@@ -391,13 +366,11 @@ final class GossipSyncManager {
private func performPeriodicMaintenance(now: Date = Date()) { private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages() cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now) cleanupStaleAnnouncementsIfNeeded(now: now)
requestSyncManager.cleanup() // Cleanup expired sync requests
for index in syncSchedules.indices { for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue } guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval { if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now syncSchedules[index].lastSent = now
sendPeriodicSync(for: syncSchedules[index].types) sendRequestSync(for: syncSchedules[index].types)
} }
} }
} }
-86
View File
@@ -1,86 +0,0 @@
//
// RequestSyncManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses.
///
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
/// that we have actively requested sync from.
final class RequestSyncManager {
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
private var pendingRequests: [PeerID: TimeInterval] = [:]
private let responseWindow: TimeInterval
private let now: () -> TimeInterval
init(
responseWindow: TimeInterval = 30.0,
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
) {
self.responseWindow = responseWindow
self.now = now
}
/// Register that we are sending a sync request to a peer.
/// - Parameter peerID: The peer we are requesting sync from
func registerRequest(to peerID: PeerID) {
let now = self.now()
queue.async(flags: .barrier) {
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync)
self.pendingRequests[peerID] = now
}
}
/// Check if a packet from a peer is a valid response to a sync request.
///
/// - Parameters:
/// - peerID: The sender of the packet
/// - isRSR: Whether the packet is marked as a Request-Sync Response
/// - Returns: true if we have a pending request for this peer and the window is open
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
guard isRSR else { return false }
return queue.sync {
guard let requestTime = pendingRequests[peerID] else {
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))", category: .security)
return false
}
let now = self.now()
if now - requestTime > responseWindow {
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
// We don't remove here because we might receive multiple packets for one request
return false
}
return true
}
}
/// Periodic cleanup of expired requests
func cleanup() {
let now = self.now()
queue.async(flags: .barrier) {
let originalCount = self.pendingRequests.count
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
now - timestamp <= self.responseWindow
}
let removed = originalCount - self.pendingRequests.count
if removed > 0 {
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
}
}
}
var debugPendingRequestCount: Int {
queue.sync { pendingRequests.count }
}
}
+22
View File
@@ -0,0 +1,22 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+6 -6
View File
@@ -21,7 +21,9 @@ struct InputValidator {
/// Rejects strings containing control characters to prevent potential security issues /// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time. /// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? { static func validateUserString(_ string: String, maxLength: Int) -> String? {
guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil } let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
guard trimmed.count <= maxLength else { return nil }
// Reject control characters outright instead of rewriting the string. // Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering. // This prevents injection attacks and ensures consistent UI rendering.
@@ -50,13 +52,11 @@ struct InputValidator {
// MessageType/NoisePayloadType enums; keeping validator free of stale lists. // MessageType/NoisePayloadType enums; keeping validator free of stale lists.
/// Validates timestamp is reasonable (not too far in past or future) /// Validates timestamp is reasonable (not too far in past or future)
/// BCH-01-011: Reduced from ±1 hour to ±5 minutes to limit replay attack window
static func validateTimestamp(_ timestamp: Date) -> Bool { static func validateTimestamp(_ timestamp: Date) -> Bool {
let now = Date() let now = Date()
// 5 minutes = 300 seconds (industry standard for replay protection) let oneHourAgo = now.addingTimeInterval(-3600)
let fiveMinutesAgo = now.addingTimeInterval(-300) let oneHourFromNow = now.addingTimeInterval(3600)
let fiveMinutesFromNow = now.addingTimeInterval(300) return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
return timestamp >= fiveMinutesAgo && timestamp <= fiveMinutesFromNow
} }
} }
@@ -1,5 +1,4 @@
import Foundation import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist. /// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
-44
View File
@@ -1,44 +0,0 @@
//
// SystemSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
enum SystemSettings {
case bluetooth
case location
case microphone
#if os(macOS)
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
private var macPrivacyAnchor: String {
switch self {
case .bluetooth: "Privacy_Bluetooth"
case .location: "Privacy_LocationServices"
case .microphone: "Privacy_Microphone"
}
}
#endif
func open() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#elseif os(macOS)
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
#endif
}
}
+115 -134
View File
@@ -78,7 +78,6 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
@@ -145,11 +144,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" { @Published var nickname: String = "" {
didSet { didSet {
// Trim whitespace whenever nickname is set; whitespace-only becomes "" // Trim whitespace whenever nickname is set
let trimmed = nickname.trimmedOrNilIfEmpty ?? "" let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed != nickname { if trimmed != nickname {
nickname = trimmed nickname = trimmed
return
} }
// Update mesh service nickname if it's initialized // Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty { if !meshService.myPeerID.isEmpty {
@@ -281,6 +279,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch // Show Tor status once per app launch
var torStatusAnnounced = false var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1
// Track whether a Tor restart is pending so we only announce // Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch. // "tor restarted" after an actual restart, not the first launch.
var torRestartPending: Bool = false var torRestartPending: Bool = false
@@ -323,6 +323,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
) )
// Channel activity tracking for background nudges // Channel activity tracking for background nudges
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participant tracker // Geohash participant tracker
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
// Participants who indicated they teleported (by tag in their events) // Participants who indicated they teleported (by tag in their events)
@@ -446,7 +448,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.deduplicationService = MessageDeduplicationService() self.deduplicationService = MessageDeduplicationService()
// Wire up dependencies // Wire up dependencies
self.commandProcessor.contextProvider = self self.commandProcessor.chatViewModel = self
self.participantTracker.configure(context: self) self.participantTracker.configure(context: self)
// Subscribe to privateChatManager changes to trigger UI updates // Subscribe to privateChatManager changes to trigger UI updates
@@ -501,6 +503,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
addGeohashOnlySystemMessage( addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting") String(localized: "system.tor.starting", comment: "System message when Tor is starting")
) )
// Suppress incremental Tor progress messages
torProgressCancellable = nil
} else if !TorManager.shared.torEnforced && !torStatusAnnounced { } else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true torStatusAnnounced = true
addGeohashOnlySystemMessage( addGeohashOnlySystemMessage(
@@ -627,6 +631,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
object: nil object: nil
) )
// Listen for delivery acknowledgments
// When app becomes active, send read receipts for visible messages // When app becomes active, send read receipts for visible messages
#if os(macOS) #if os(macOS)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -749,7 +755,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private func loadNickname() { private func loadNickname() {
if let savedNickname = userDefaults.string(forKey: nicknameKey) { if let savedNickname = userDefaults.string(forKey: nicknameKey) {
nickname = savedNickname.trimmed // Trim whitespace when loading
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
} else { } else {
nickname = "anon\(Int.random(in: 1000...9999))" nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname() saveNickname()
@@ -765,10 +772,20 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
func validateAndSaveNickname() { func validateAndSaveNickname() {
nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))" // Trim whitespace from nickname
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Check if nickname is empty after trimming
if trimmed.isEmpty {
nickname = "anon\(Int.random(in: 1000...9999))"
} else {
nickname = trimmed
}
saveNickname() saveNickname()
} }
// MARK: - Favorites Management
// MARK: - Blocked Users Management (Delegated to PeerStateManager) // MARK: - Blocked Users Management (Delegated to PeerStateManager)
@@ -989,7 +1006,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
@MainActor @MainActor
func sendMessage(_ content: String) { func sendMessage(_ content: String) {
// Ignore messages that are empty or whitespace-only to prevent blank lines // Ignore messages that are empty or whitespace-only to prevent blank lines
guard let trimmed = content.trimmedOrNilIfEmpty else { return } let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
// Check for commands // Check for commands
if content.hasPrefix("/") { if content.hasPrefix("/") {
@@ -1450,6 +1468,56 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
selectedPrivateChatFingerprint = nil selectedPrivateChatFingerprint = nil
} }
// MARK: - Nostr Message Handling
@MainActor
@objc private func handleNostrMessage(_ notification: Notification) {
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
// Store the Nostr pubkey if provided (for messages from unknown senders)
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
let senderPeerID = message.senderPeerID {
// Store mapping for read receipts
nostrKeyMapping[senderPeerID] = nostrPubkey
}
// Process the Nostr message through the same flow as Bluetooth messages
didReceiveMessage(message)
}
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
// Update the delivery status for the message
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status to delivered
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// Schedule UI update for delivery status
// UI will update automatically
}
// Also update in private chats if it's a private message
for (peerID, chatMessages) in privateChats {
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// UI will update automatically
break
}
}
}
@objc private func handleNostrReadReceipt(_ notification: Notification) {
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
// Process the read receipt through the same flow as Bluetooth read receipts
didReceiveReadReceipt(receipt)
}
@MainActor @MainActor
@objc private func handlePeerStatusUpdate(_ notification: Notification) { @objc private func handlePeerStatusUpdate(_ notification: Notification) {
// Update private chat peer if needed when peer status changes // Update private chat peer if needed when peer status changes
@@ -1801,14 +1869,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
} }
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
if let peerID {
return getPrivateChatMessages(for: peerID)
} else {
return messages
}
}
@MainActor @MainActor
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = [] var combined: [BitchatMessage] = []
@@ -1995,12 +2055,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} catch { } catch {
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session) SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
} }
// BCH-01-013: Clear iOS app switcher snapshots
// These are stored in Library/Caches/Snapshots/<bundle_id>/
#if os(iOS)
Self.clearAppSwitcherSnapshots()
#endif
} }
// Force immediate UI update for panic mode // Force immediate UI update for panic mode
@@ -2008,29 +2062,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
/// iOS stores preview screenshots in Library/Caches/Snapshots/<bundle_id>/
/// These could reveal sensitive information visible in the app at the time
#if os(iOS)
private nonisolated static func clearAppSwitcherSnapshots() {
do {
let cacheDir = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let snapshotsDir = cacheDir.appendingPathComponent("Snapshots", isDirectory: true)
// Clear all snapshots (iOS stores them in subdirectories by bundle ID and scene)
if FileManager.default.fileExists(atPath: snapshotsDir.path) {
let contents = try FileManager.default.contentsOfDirectory(at: snapshotsDir, includingPropertiesForKeys: nil)
for item in contents {
try FileManager.default.removeItem(at: item)
}
SecureLogger.info("🗑️ Cleared app switcher snapshots during panic clear", category: .session)
}
} catch {
SecureLogger.error("Failed to clear app switcher snapshots: \(error)", category: .session)
}
}
#endif
// MARK: - Autocomplete // MARK: - Autocomplete
func updateAutocomplete(for text: String, cursorPosition: Int) { func updateAutocomplete(for text: String, cursorPosition: Int) {
@@ -3000,7 +3031,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
Task { @MainActor in Task { @MainActor in
// Early validation // Early validation
guard !isMessageBlocked(message) else { return } guard !isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return } guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return }
// Route to appropriate handler // Route to appropriate handler
if message.isPrivate { if message.isPrivate {
@@ -3015,91 +3046,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
} }
/// Find message index trying both short (16-hex) and long (64-hex) peer ID formats.
/// Returns the peer ID where the message was found and its index, or nil if not found.
private func findMessageIndex(messageID: String, peerID: PeerID) -> (peerID: PeerID, index: Int)? {
// Try direct lookup first
if let messages = privateChats[peerID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (peerID, idx)
}
// Try with full noise key if peerID is short (16 hex chars)
if peerID.bare.count == 16,
let peer = unifiedPeerService.getPeer(by: peerID),
!peer.noisePublicKey.isEmpty {
let longID = PeerID(hexData: peer.noisePublicKey)
if let messages = privateChats[longID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (longID, idx)
}
}
// Try with short form if peerID is long (64 hex = noise key)
if peerID.bare.count == 64 {
let shortID = peerID.toShort()
if let messages = privateChats[shortID],
let idx = messages.firstIndex(where: { $0.id == messageID }) {
return (shortID, idx)
}
}
return nil
}
// Low-level BLE events // Low-level BLE events
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) { func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
Task { @MainActor in Task { @MainActor in
switch type { switch type {
case .privateMessage: case .privateMessage:
guard let pm = PrivateMessagePacket.decode(from: payload) else { return } guard let pm = PrivateMessagePacket.decode(from: payload) else { return }
// BCH-01-012: Check blocking before processing private message to prevent notification bypass
if isPeerBlocked(peerID) {
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
return
}
let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown" let senderName = unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let pmMentions = parseMentions(from: pm.content) let pmMentions = parseMentions(from: pm.content)
let msg = BitchatMessage( let msg = BitchatMessage(
id: pm.messageID, id: pm.messageID,
sender: senderName, sender: senderName,
content: pm.content, content: pm.content,
timestamp: timestamp, timestamp: timestamp,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
isPrivate: true, isPrivate: true,
recipientNickname: nickname, recipientNickname: nickname,
senderPeerID: peerID, senderPeerID: peerID,
mentions: pmMentions.isEmpty ? nil : pmMentions mentions: pmMentions.isEmpty ? nil : pmMentions
) )
handlePrivateMessage(msg) handlePrivateMessage(msg)
// Send delivery ACK back over BLE // Send delivery ACK back over BLE
meshService.sendDeliveryAck(for: pm.messageID, to: peerID) meshService.sendDeliveryAck(for: pm.messageID, to: peerID)
case .delivered: case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
// Don't downgrade from .read to .delivered objectWillChange.send()
if case .read = privateChats[foundPeerID]?[idx].deliveryStatus { return } }
}
privateChats[foundPeerID]?[idx].deliveryStatus = .delivered(to: name, at: Date())
objectWillChange.send()
case .readReceipt: case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
guard let name = unifiedPeerService.getPeer(by: peerID)?.nickname, if let name = unifiedPeerService.getPeer(by: peerID)?.nickname {
let (foundPeerID, idx) = findMessageIndex(messageID: messageID, peerID: peerID) else { return } if let messages = privateChats[peerID], let idx = messages.firstIndex(where: { $0.id == messageID }) {
privateChats[peerID]?[idx].deliveryStatus = .read(by: name, at: Date())
// Explicitly unwrap and re-assign to ensure the @Published setter is called objectWillChange.send()
if let messages = privateChats[foundPeerID], idx < messages.count { }
messages[idx].deliveryStatus = .read(by: name, at: Date())
privateChats[foundPeerID] = messages
privateChatManager.objectWillChange.send()
objectWillChange.send()
} }
case .verifyChallenge: case .verifyChallenge:
// Parse and respond // Parse and respond
@@ -3173,7 +3159,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
Task { @MainActor in Task { @MainActor in
let normalized = content.trimmed let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines)
let publicMentions = parseMentions(from: normalized) let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage( let msg = BitchatMessage(
id: messageID, id: messageID,
@@ -3346,25 +3332,18 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.scheduleNetworkEmptyTimer() self.scheduleNetworkEmptyTimer()
} else { } else {
self.invalidateNetworkEmptyTimer() self.invalidateNetworkEmptyTimer()
// Don't trim recentlySeenPeers here - let timers handle cleanup. // Trim out peers we no longer observe before comparing for new arrivals
// Trimming immediately causes peers to be treated as "new" when they self.recentlySeenPeers.formIntersection(meshPeerSet)
// briefly drop and reconnect, triggering notification floods.
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
if !newPeers.isEmpty { if !newPeers.isEmpty {
// Rate limit: max one notification per 5 minutes self.lastNetworkNotificationTime = Date()
let cooldown = TransportConfig.networkNotificationCooldownSeconds self.recentlySeenPeers.formUnion(newPeers)
if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown { NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
// Only mark peers as seen when we actually notify about them SecureLogger.info(
// This ensures peers arriving during cooldown will be included in the next notification "👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
self.recentlySeenPeers.formUnion(newPeers) category: .session
self.lastNetworkNotificationTime = Date() )
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
}
self.scheduleNetworkResetTimer() self.scheduleNetworkResetTimer()
} }
} }
@@ -3781,8 +3760,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Removed background nudge notification for generic "new chats!" // Removed background nudge notification for generic "new chats!"
// Append via batching buffer (skip empty content) with simple dedup by ID // Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) { if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
publicMessagePipeline.enqueue(finalMessage) if !messages.contains(where: { $0.id == finalMessage.id }) {
publicMessagePipeline.enqueue(finalMessage)
}
} }
} }
@@ -8,7 +8,6 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
import Tor import Tor
@@ -57,9 +56,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id) !deduplicationService.hasProcessedNostrEvent(event.id)
else { else {
return return
@@ -78,7 +75,7 @@ extension ChatViewModel {
} }
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
geoNicknames[event.pubkey.lowercased()] = nick geoNicknames[event.pubkey.lowercased()] = nick
} }
@@ -89,11 +86,6 @@ extension ChatViewModel {
// Update participants last-seen for this pubkey // Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey) participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// If presence heartbeat (Kind 20001), stop here - no content to display
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
// Track teleported tag (only our format ["t","teleport"]) for icon state // Track teleported tag (only our format ["t","teleport"]) for icon state
let hasTeleportTag = event.tags.contains(where: { tag in let hasTeleportTag = event.tags.contains(where: { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -116,7 +108,7 @@ extension ChatViewModel {
} }
let senderName = displayNameForNostrPubkey(event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
// Clamp future timestamps to now to avoid future-dated messages skewing order // Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -132,28 +124,20 @@ extension ChatViewModel {
mentions: mentions.isEmpty ? nil : mentions mentions: mentions.isEmpty ? nil : mentions
) )
Task { @MainActor in Task { @MainActor in
// BCH-01-012: Check blocking before any notifications
// handlePublicMessage has its own blocking check but returns silently,
// so we must also guard checkForMentions to prevent notification bypass
let isBlocked = identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
handlePublicMessage(msg) handlePublicMessage(msg)
checkForMentions(msg)
// Only check mentions and send haptic if sender is not blocked sendHapticFeedback(for: msg)
if !isBlocked {
checkForMentions(msg)
sendHapticFeedback(for: msg)
}
} }
} }
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id) deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
let packet = Self.decodeEmbeddedBitChatPacket(from: content), content.hasPrefix("bitchat1:"),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload) let noisePayload = NoisePayload.decode(packet.payload)
else { else {
@@ -193,7 +177,7 @@ extension ChatViewModel {
case .mesh: case .mesh:
refreshVisibleMessages(from: .mesh) refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present // Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyMesh > 0 { if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
} }
@@ -255,10 +239,8 @@ extension ChatViewModel {
} }
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return } // Only handle ephemeral kind 20000 with matching tag
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Deduplicate // Deduplicate
if deduplicationService.hasProcessedNostrEvent(event.id) { return } if deduplicationService.hasProcessedNostrEvent(event.id) { return }
@@ -268,11 +250,6 @@ extension ChatViewModel {
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",") let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session) SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
// Track teleport tag for participants only our format ["t", "teleport"] // Track teleport tag for participants only our format ["t", "teleport"]
let hasTeleportTag: Bool = event.tags.contains { tag in let hasTeleportTag: Bool = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport" tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
@@ -296,9 +273,6 @@ extension ChatViewModel {
} }
} }
// Update participants last-seen for this pubkey
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
// Skip only very recent self-echo from relay; include older self events for hydration // Skip only very recent self-echo from relay; include older self events for hydration
if isSelf { if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -309,26 +283,28 @@ extension ChatViewModel {
// Cache nickname from tag if present // Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines)
geoNicknames[event.pubkey.lowercased()] = nick
}
// If this pubkey is blocked, skip mapping, participants, and timeline
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
} }
// Store mapping for geohash DM initiation // Store mapping for geohash DM initiation
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
// If presence heartbeat (Kind 20001), stop here - no content to display // Update participants last-seen for this pubkey
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue { participantTracker.recordParticipant(pubkeyHex: event.pubkey)
return
}
let senderName = displayNameForNostrPubkey(event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content let content = event.content
// If this is a teleport presence event (no content), don't add to timeline // If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }), if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"),
teleTag.count >= 2, content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return return
} }
@@ -370,7 +346,6 @@ extension ChatViewModel {
} }
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return return
} }
@@ -384,7 +359,9 @@ extension ChatViewModel {
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content), guard content.hasPrefix("bitchat1:"),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) let payload = NoisePayload.decode(packet.payload)
else { else {
@@ -487,9 +464,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent, gh: String) { func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
// Compute current participant count (5-minute window) BEFORE updating with this event // Compute current participant count (5-minute window) BEFORE updating with this event
let existingCount = participantTracker.participantCount(for: gh) let existingCount = participantTracker.participantCount(for: gh)
@@ -498,7 +473,8 @@ extension ChatViewModel {
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat // Notify only on rising-edge: previously zero people, now someone sends a chat
guard let content = event.content.trimmedOrNilIfEmpty else { return } let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
// Respect geohash blocks // Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
@@ -604,7 +580,6 @@ extension ChatViewModel {
} }
func processNostrMessage(_ giftWrap: NostrEvent) async { func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do { do {
@@ -622,7 +597,8 @@ extension ChatViewModel {
// Check if it's a BitChat packet embedded in the content (bitchat1:...) // Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") { if content.hasPrefix("bitchat1:") {
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else { guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return return
} }
@@ -633,23 +609,24 @@ extension ChatViewModel {
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue, if packet.type == MessageType.noiseEncrypted.rawValue {
let payload = NoisePayload.decode(packet.payload) { if let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping // Store Nostr mapping
await MainActor.run { await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types // Handle packet types
switch payload.type { switch payload.type {
case .privateMessage: case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp) handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered: case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt: case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
}
} }
} }
} }
@@ -802,19 +779,6 @@ extension ChatViewModel {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} }
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash) // MARK: - Geohash Nickname Resolution (for /block in geohash)
func nostrPubkeyForDisplayName(_ name: String) -> String? { func nostrPubkeyForDisplayName(_ name: String) -> String? {
@@ -8,7 +8,6 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
extension ChatViewModel { extension ChatViewModel {
@@ -320,7 +319,7 @@ extension ChatViewModel {
} }
let targetPeer = selectedPrivateChatPeer let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer) let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id let messageID = message.id
let transferId = makeTransferID(messageID: messageID) let transferId = makeTransferID(messageID: messageID)
@@ -365,36 +364,6 @@ extension ChatViewModel {
} }
} }
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
@MainActor @MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard canSendMediaInCurrentContext else { guard canSendMediaInCurrentContext else {
@@ -429,7 +398,7 @@ extension ChatViewModel {
) )
guard packet.encode() != nil else { throw MediaSendError.encodingFailed } guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run { await MainActor.run {
let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer) let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID) let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -548,19 +517,20 @@ extension ChatViewModel {
func cleanupLocalFile(forMessage message: BitchatMessage) { func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup // Check both outgoing and incoming directories for thorough cleanup
let categories: [MimeType.Category] = [.audio, .image, .file] let prefixes = ["[voice] ", "[image] ", "[file] "]
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }), let subdirs = ["voicenotes/outgoing", "voicenotes/incoming",
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty, "images/outgoing", "images/incoming",
let base = try? applicationFilesDirectory(), "files/outgoing", "files/incoming"]
// Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty, guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return }
safeFilename != "." && safeFilename != ".." let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines)
else { guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return }
return
} // Security: Extract only the last path component to prevent directory traversal
let safeFilename = (rawFilename as NSString).lastPathComponent
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
// Try all possible locations (outgoing and incoming) // Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs { for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
@@ -770,11 +740,15 @@ extension ChatViewModel {
if isViewing { if isViewing {
// Mark read immediately if viewing // Mark read immediately if viewing
// Use the incoming peerID directly - it has the established Noise session. // Use router to send read receipt
// Don't use PeerID(hexData: noiseKey) as that creates a 64-hex ID without a session.
// Use meshService directly (not messageRouter) so it queues if peer disconnects.
let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname) let receipt = ReadReceipt(originalMessageID: message.id, readerID: meshService.myPeerID, readerNickname: nickname)
meshService.sendReadReceipt(receipt, to: peerID) if let key = noiseKey {
// Send via router to stable key if available (preferred for persistence/Nostr fallback)
messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
} else {
// Fallback to mesh direct
meshService.sendReadReceipt(receipt, to: peerID)
}
sentReadReceipts.insert(message.id) sentReadReceipts.insert(message.id)
} else { } else {
// Notify // Notify
@@ -1,142 +0,0 @@
//
// VoiceRecordingViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
@MainActor
final class VoiceRecordingViewModel: ObservableObject {
enum State: Equatable {
case idle
case requestingPermission
case permissionDenied
case preparing
case recording(startDate: Date)
case error(message: String)
var isActive: Bool {
switch self {
case .preparing, .recording: true
case .idle, .requestingPermission, .permissionDenied, .error: false
}
}
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
case .idle, .requestingPermission, .preparing, .recording: ""
}
}
fileprivate func duration(for date: Date) -> TimeInterval {
switch self {
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
case .recording(let startDate): date.timeIntervalSince(startDate)
}
}
}
var showAlert: Bool {
get {
switch state {
case .permissionDenied, .error: true
case .idle, .requestingPermission, .preparing, .recording: false
}
}
set {
if !newValue { state = .idle }
}
}
@Published private(set) var state = State.idle
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
let totalMilliseconds = Int(clamped * 1000)
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds % 60_000) / 1_000
let centiseconds = (totalMilliseconds % 1_000) / 10
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
state = .requestingPermission
Task {
let granted = await VoiceRecorder.shared.requestPermission()
guard state == .requestingPermission else { return }
guard granted else {
state = .permissionDenied
return
}
state = .preparing
do {
try await VoiceRecorder.shared.startRecording()
guard state == .preparing else {
cancel()
return
}
state = .recording(startDate: Date())
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await VoiceRecorder.shared.cancelRecording()
guard state == .preparing else { return }
state = .error(message: "Could not start recording.")
}
}
}
func finish(completion: ((URL) -> Void)?) {
let previousState = state
switch previousState {
case .permissionDenied, .error:
return
case .idle, .requestingPermission, .preparing, .recording:
break
}
state = .idle
guard case .recording(let startDate) = previousState, let completion else {
Task { await VoiceRecorder.shared.cancelRecording() }
return
}
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await VoiceRecorder.shared.stopRecording(),
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
}
}
}
func cancel() {
finish(completion: nil)
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 0,
duration >= VoiceRecorder.minRecordingDuration {
return true
}
try? FileManager.default.removeItem(at: url)
return false
}
}
+22
View File
@@ -86,6 +86,10 @@ struct AppInfoView: View {
] ]
} }
enum Warning {
static let title: LocalizedStringKey = "app_info.warning.title"
static let message: LocalizedStringKey = "app_info.warning.message"
}
} }
var body: some View { var body: some View {
@@ -188,6 +192,24 @@ struct AppInfoView: View {
FeatureRow(info: Strings.Privacy.panic) FeatureRow(info: Strings.Privacy.panic)
} }
// Warning
VStack(alignment: .leading, spacing: 6) {
SectionHeader(Strings.Warning.title)
.foregroundColor(Color.red)
Text(Strings.Warning.message)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(Color.red)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 6)
.padding(.bottom, 16)
.padding(.horizontal)
.background(Color.red.opacity(0.1))
.cornerRadius(8)
.padding(.top)
} }
.padding() .padding()
} }
+1 -13
View File
@@ -16,21 +16,9 @@ struct PaymentChipView: View {
case cashu(String) case cashu(String)
case lightning(String) case lightning(String)
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
private static func cashuURL(from link: String) -> URL? {
if let url = URL(string: link), url.scheme != nil {
return url
}
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
return URL(string: "cashu:\(enc)")
}
var url: URL? { var url: URL? {
switch self { switch self {
case .cashu(let link): case .cashu(let link), .lightning(let link):
return Self.cashuURL(from: link)
case .lightning(let link):
return URL(string: link) return URL(string: link)
} }
} }
@@ -13,7 +13,7 @@ struct TextMessageView: View {
@EnvironmentObject private var viewModel: ChatViewModel @EnvironmentObject private var viewModel: ChatViewModel
let message: BitchatMessage let message: BitchatMessage
@State private var expandedMessageIDs: Set<String> = [] @Binding var expandedMessageIDs: Set<String>
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -66,12 +66,14 @@ struct TextMessageView: View {
} }
} }
@available(macOS 14, iOS 17, *)
#Preview { #Preview {
@Previewable @State var ids: Set<String> = []
let keychain = PreviewKeychainManager() let keychain = PreviewKeychainManager()
Group { Group {
List { List {
TextMessageView(message: .preview) TextMessageView(message: .preview, expandedMessageIDs: $ids)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(EmptyView()) .listRowBackground(EmptyView())
@@ -79,7 +81,7 @@ struct TextMessageView: View {
.environment(\.colorScheme, .light) .environment(\.colorScheme, .light)
List { List {
TextMessageView(message: .preview) TextMessageView(message: .preview, expandedMessageIDs: $ids)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(EmptyView()) .listRowBackground(EmptyView())
File diff suppressed because it is too large Load Diff
-1
View File
@@ -7,7 +7,6 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct FingerprintView: View { struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
@@ -1,79 +0,0 @@
//
// ImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import SwiftUI
/// Camera or Photo Library
struct ImagePickerView: UIViewControllerRepresentable {
let sourceType: UIImagePickerController.SourceType
let completion: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = sourceType
picker.delegate = context.coordinator
picker.allowsEditing = false
// Use standard full screen - iOS handles safe areas automatically
picker.modalPresentationStyle = .fullScreen
// Force dark mode to make safe area bars black instead of white
picker.overrideUserInterfaceStyle = .dark
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: completion)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let completion: (UIImage?) -> Void
init(completion: @escaping (UIImage?) -> Void) {
self.completion = completion
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
let image = info[.originalImage] as? UIImage
completion(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
completion(nil)
}
}
}
@available(iOS 17, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: UIImage?
VStack {
if let selectedImage {
Image(uiImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
ImagePickerView(sourceType: .photoLibrary) { image in
selectedImage = image
isPresented = false
}
}
}
#endif
@@ -1,147 +0,0 @@
//
// ImagePreviewView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(macOS)
import BitLogger
#endif
struct ImagePreviewView: View {
let url: URL
@Environment(\.dismiss) private var dismiss
#if os(iOS)
@State private var showExporter = false
@State private var platformImage: UIImage?
#else
@State private var platformImage: NSImage?
#endif
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
Spacer()
if let image = platformImage {
#if os(iOS)
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#else
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#endif
} else {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
}
Spacer()
HStack {
Button(action: { dismiss() }) {
Text("close", comment: "Button to dismiss fullscreen media viewer")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
}
Spacer()
Button(action: saveCopy) {
Text("save", comment: "Button to save media to device")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
}
}
.padding([.horizontal, .bottom], 24)
}
}
.onAppear(perform: loadImage)
#if os(iOS)
.sheet(isPresented: $showExporter) {
FileExportWrapper(url: url)
}
#endif
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
DispatchQueue.main.async {
self.platformImage = image
}
}
}
private func saveCopy() {
#if os(iOS)
showExporter = true
#else
Task { @MainActor in
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.nameFieldStringValue = url.lastPathComponent
panel.prompt = "save"
if panel.runModal() == .OK, let destination = panel.url {
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
} catch {
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
}
}
}
#endif
}
#if os(iOS)
private struct FileExportWrapper: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let controller = UIDocumentPickerViewController(forExporting: [url])
controller.shouldShowFileExtensions = true
return controller
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
}
#endif
}
#Preview {
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
#if os(iOS)
let image = UIImage(named: "dummy")
let data = image?.jpegData(compressionQuality: 0.8)
let _ = try? data?.write(to: tempURL)
#elseif os(macOS)
let image = NSImage(named: "dummy")
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
let rep = NSBitmapImageRep(cgImage: cgImage)
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
let _ = try? jpegData?.write(to: tempURL)
}
#endif
}
ImagePreviewView(url: tempURL)
}
@@ -1,71 +0,0 @@
//
// MacImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(macOS)
import SwiftUI
struct MacImagePickerView: View {
let completion: (URL?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Choose an image")
.font(.headline)
Button("Select Image") {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
panel.message = "Choose an image to send"
if panel.runModal() == .OK {
completion(panel.url)
} else {
dismiss()
}
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
completion(nil)
}
.buttonStyle(.bordered)
}
.padding(40)
.frame(minWidth: 300, minHeight: 150)
}
}
@available(OSX 14, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: NSImage?
VStack {
if let selectedImage {
Image(nsImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
MacImagePickerView { url in
selectedImage = url.map(NSImage.init)
isPresented = false
}
}
}
#endif
+21 -25
View File
@@ -40,31 +40,10 @@ struct LocationChannelsSheet: View {
} }
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String { static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
// High-precision uncertainty: if count is 0 for high-precision levels,
// show "?" because presence broadcasting is disabled for privacy.
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
level.displayName
)
}
return rowTitle(label: level.displayName, count: count) return rowTitle(label: level.displayName, count: count)
} }
static func bookmarkTitle(geohash: String, count: Int) -> String { static func bookmarkTitle(geohash: String, count: Int) -> String {
// Check precision for bookmarks too
let len = geohash.count
// Neighborhood=6, Block=7, Building=8+
let isHighPrecision = (len >= 6)
if isHighPrecision && count == 0 {
return String(
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
locale: .current,
"#\(geohash)"
)
}
return rowTitle(label: "#\(geohash)", count: count) return rowTitle(label: "#\(geohash)", count: count)
} }
@@ -125,7 +104,7 @@ struct LocationChannelsSheet: View {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open) Button(Strings.openSettings) { openSystemLocationSettings() }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
case LocationChannelManager.PermissionState.authorized: case LocationChannelManager.PermissionState.authorized:
@@ -246,7 +225,9 @@ struct LocationChannelsSheet: View {
sectionDivider sectionDivider
torToggleSection torToggleSection
.padding(.top, 12) .padding(.top, 12)
Button(action: SystemSettings.location.open) { Button(action: {
openSystemLocationSettings()
}) {
Text(Strings.removeAccess) Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
@@ -302,7 +283,7 @@ struct LocationChannelsSheet: View {
} }
} }
let normalized = customGeohash let normalized = customGeohash
.trimmed .trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() .lowercased()
.replacingOccurrences(of: "#", with: "") .replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized) let isValid = validateGeohash(normalized)
@@ -449,7 +430,7 @@ struct LocationChannelsSheet: View {
// 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]"
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) { private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
guard let idx = s.lastIndex(of: "[") else { return (s, nil) } guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
let prefix = String(s[..<idx]).trimmed let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
let suffix = String(s[idx...]) let suffix = String(s[idx...])
return (prefix, suffix) return (prefix, suffix)
} }
@@ -620,3 +601,18 @@ extension LocationChannelsSheet {
} }
} }
} }
// MARK: - Open Settings helper
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
+5 -8
View File
@@ -12,15 +12,11 @@ struct LocationNotesView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var draft: String = "" @State private var draft: String = ""
init( init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) {
geohash: String,
onNotesCountChanged: ((Int) -> Void)? = nil,
manager: LocationNotesManager? = nil
) {
let gh = geohash.lowercased() let gh = geohash.lowercased()
self.geohash = gh self.geohash = gh
self.onNotesCountChanged = onNotesCountChanged self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh)) _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
} }
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
@@ -264,13 +260,14 @@ struct LocationNotesView: View {
} }
private func send() { private func send() {
guard let content = draft.trimmedOrNilIfEmpty else { return } let content = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname) manager.send(content: content, nickname: viewModel.nickname)
draft = "" draft = ""
} }
private var sendButtonEnabled: Bool { private var sendButtonEnabled: Bool {
!draft.trimmed.isEmpty && manager.state != .noRelays !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
} }
// MARK: - Timestamp Formatting // MARK: - Timestamp Formatting
@@ -1,87 +0,0 @@
//
// MediaMessageView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import SwiftUI
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
let message: BitchatMessage
let media: BitchatMessage.Media
@Binding var imagePreviewURL: URL?
var body: some View {
let state = mediaSendState(for: message)
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
.padding(.vertical, 4)
}
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
var isSending = false
var progress: Double?
if let status = message.deliveryStatus {
switch status {
case .sending:
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .read, .delivered, .failed:
break
}
}
let canCancel = isSending && message.sender == viewModel.nickname
let clamped = progress.map { max(0, min(1, $0)) }
return (isSending, isSending ? clamped : nil, canCancel)
}
}
+17 -3
View File
@@ -34,10 +34,24 @@ struct VoiceNoteView: View {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
} }
private var durationText: String {
let duration = playback.duration
guard duration.isFinite, duration > 0 else { return "--:--" }
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var currentText: String {
let current = playback.currentTime
guard current.isFinite, current > 0 else { return "00:00" }
let minutes = Int(current) / 60
let seconds = Int(current) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var playbackLabel: String { private var playbackLabel: String {
guard playback.duration.isFinite else { return "--:--" } playback.isPlaying ? currentText + "/" + durationText : durationText
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
} }
var body: some View { var body: some View {
-1
View File
@@ -1,5 +1,4 @@
import SwiftUI import SwiftUI
import BitFoundation
struct MeshPeerList: View { struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
-449
View File
@@ -1,449 +0,0 @@
//
// MessageListView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import BitFoundation
import SwiftUI
private struct MessageDisplayItem: Identifiable {
let id: String
let message: BitchatMessage
}
struct MessageListView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.colorScheme) private var colorScheme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var showSidebar: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
}
return windowCountPublic
}()
let messages = viewModel.getMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
guard !message.content.trimmed.isEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
messageRow(for: message)
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
expandWindow(
ifNeededFor: message,
allMessages: messages,
privatePeer: privatePeer,
proxy: proxy
)
}
}
.onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom = false
}
}
.contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
}
}
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
viewModel.sendMessage("/clear")
}
.onAppear {
scrollToBottom(on: proxy)
}
.onChange(of: privatePeer) { _ in
scrollToBottom(on: proxy)
}
.onChange(of: viewModel.messages.count) { _ in
onMessagesChange(proxy: proxy)
}
.onChange(of: viewModel.privateChats) { _ in
onPrivateChatsChange(proxy: proxy)
}
.onChange(of: locationManager.selectedChannel) { newChannel in
onSelectedChannelChange(newChannel, proxy: proxy)
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
}
}
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
Button("content.actions.hug") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/hug @\(sender)")
}
}
Button("content.actions.slap") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/slap @\(sender)")
}
}
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
}
}
.onDisappear {
scrollThrottleTimer?.invalidate()
}
}
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
}
}
private extension MessageListView {
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
if message.sender == "system" {
systemMessageRow(message)
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else {
TextMessageView(message: message)
}
}
}
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: PeerID?,
proxy: ScrollViewProxy) {
let step = TransportConfig.uiWindowStepCount
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPrivate[peer] = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
} else {
let current = windowCountPublic
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPublic = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
}
func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
case "geohash":
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(channel))
default:
return
}
}
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if let secondTarget = self.targetPeerID {
proxy.scrollTo(secondTarget, anchor: .bottom)
}
}
}
var targetPeerID: String? {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
if let last = viewModel.messages.suffix(300).last?.id {
return "\(locationManager.selectedChannel.contextKey)|\(last)"
}
return nil
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
// If the newest message is from me, always scroll to bottom
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = locationManager.selectedChannel.contextKey
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
// Throttle scroll animations to prevent excessive UI updates
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
// Immediate scroll if enough time has passed
scrollIfNeeded(date: now)
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
return
}
// If the newest private message is from me, always scroll
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else {
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
proxy.scrollTo(target, anchor: .bottom)
}
}
// Same throttling for private chats
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
scrollIfNeeded(date: now)
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
switch channel {
case .mesh:
break
case .location(let ch):
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
}
private extension ChannelID {
var contextKey: String {
switch self {
case .mesh: "mesh"
case .location(let ch): "geo:\(ch.geohash)"
}
}
}
//#Preview {
// MessageListView()
//}
+19 -4
View File
@@ -5,6 +5,21 @@
import Foundation import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String { extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout // Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool { func hasVeryLongToken(threshold: Int) -> Bool {
@@ -23,7 +38,7 @@ extension String {
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuLinks(max: Int = 3) -> [String] { func extractCashuLinks(max: Int = 3) -> [String] {
let regex = MessageFormattingEngine.Patterns.cashu let regex = RegexCache.cashu
let ns = self as NSString let ns = self as NSString
let range = NSRange(location: 0, length: ns.length) let range = NSRange(location: 0, length: ns.length)
var found: [String] = [] var found: [String] = []
@@ -44,19 +59,19 @@ extension String {
let ns = self as NSString let ns = self as NSString
let full = NSRange(location: 0, length: ns.length) let full = NSRange(location: 0, length: ns.length)
// lightning: scheme // lightning: scheme
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) { for m in RegexCache.lightningScheme.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append(s) results.append(s)
if results.count >= max { return results } if results.count >= max { return results }
} }
// BOLT11 // BOLT11
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) { for m in RegexCache.bolt11.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
} }
// LNURL bech32 // LNURL bech32
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) { for m in RegexCache.lnurl.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0)) let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)") results.append("lightning:\(s)")
if results.count >= max { return results } if results.count >= max { return results }
@@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -1,21 +0,0 @@
{
"images" : [
{
"filename" : "dummy.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

@@ -41,19 +41,6 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
storage["identity_noiseStaticKey"] != nil storage["identity_noiseStaticKey"] != nil
} }
// BCH-01-009: New methods with proper error classification
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
if let data = storage[key] {
return .success(data)
}
return .itemNotFound
}
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
storage[key] = keyData
return .success
}
// MARK: - Generic Data Storage (consolidated from KeychainHelper) // MARK: - Generic Data Storage (consolidated from KeychainHelper)
func save(key: String, data: Data, service: String, accessible: CFString?) { func save(key: String, data: Data, service: String, accessible: CFString?) {
+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(APP_GROUP_ID)</string> <string>group.chat.bitchat</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(APP_GROUP_ID)</string> <string>group.chat.bitchat</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
-2
View File
@@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
/// Avoids deprecated Social framework and SLComposeServiceViewController. /// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController { final class ShareViewController: UIViewController {
// Bundle.main.bundleIdentifier would get the extension's bundleID // Bundle.main.bundleIdentifier would get the extension's bundleID
private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat" private static let groupID = "group.chat.bitchat"
private enum Strings { private enum Strings {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>$(APP_GROUP_ID)</string> <string>group.chat.bitchat</string>
</array> </array>
</dict> </dict>
</plist> </plist>
-154
View File
@@ -1,154 +0,0 @@
//
// BLEServiceCoreTests.swift
// bitchatTests
//
// Focused BLEService tests for packet handling behavior.
//
import Testing
import Foundation
import CoreBluetooth
import BitFoundation
@testable import bitchat
struct BLEServiceCoreTests {
@Test
func duplicatePacket_isDeduped() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(receivedFirst)
ble._test_handlePacket(packet, fromPeerID: sender)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
)
#expect(!receivedDuplicate)
let messages = delegate.publicMessagesSnapshot()
#expect(messages.count == 1)
#expect(messages.first?.content == "Hello")
}
@Test
func staleBroadcast_isIgnored() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "A1B2C3D4E5F60708")
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
#expect(!didReceive)
#expect(delegate.publicMessagesSnapshot().isEmpty)
}
@Test
func announceSenderMismatch_isRejected() async throws {
let ble = makeService()
let signer = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "Spoof",
noisePublicKey: signer.getStaticPublicKeyData(),
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
let wrongPeerID = PeerID(str: wrongBare)
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
#expect(ble.currentPeerSnapshots().isEmpty)
}
}
private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
}
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: timestamp,
payload: Data(content.utf8),
signature: nil,
ttl: 3
)
}
private final class PublicCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
lock.lock()
publicMessages.append(message)
lock.unlock()
}
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func publicMessagesSnapshot() -> [BitchatMessage] {
lock.lock()
defer { lock.unlock() }
return publicMessages
}
}
+7 -8
View File
@@ -8,7 +8,6 @@
import Testing import Testing
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceTests { struct BLEServiceTests {
@@ -74,7 +73,7 @@ struct BLEServiceTests {
service.sendMessage("Hello, world!") service.sendMessage("Hello, world!")
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
#expect(service.sentMessages.count == 1) #expect(service.sentMessages.count == 1)
} }
@@ -98,7 +97,7 @@ struct BLEServiceTests {
) )
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
#expect(service.sentMessages.count == 1) #expect(service.sentMessages.count == 1)
} }
@@ -114,7 +113,7 @@ struct BLEServiceTests {
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"]) service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
} }
@@ -147,7 +146,7 @@ struct BLEServiceTests {
service.simulateIncomingMessage(incomingMessage) service.simulateIncomingMessage(incomingMessage)
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
} }
@@ -190,7 +189,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet) service.simulateIncomingPacket(packet)
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
} }
@@ -232,7 +231,7 @@ struct BLEServiceTests {
service.sendMessage("Test delivery") service.sendMessage("Test delivery")
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
} }
@@ -274,7 +273,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet) service.simulateIncomingPacket(packet)
// Allow async processing // Allow async processing
try await sleep(1.0) try await sleep(0.5)
} }
} }
} }
-84
View File
@@ -1,84 +0,0 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite("BitchatPeer Tests")
struct BitchatPeerTests {
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
func connectionStatePriorityIsCorrect() {
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
nostr.favoriteStatus = mutual
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
#expect(bluetooth.connectionState == .bluetoothConnected)
#expect(mesh.connectionState == .meshReachable)
#expect(nostr.connectionState == .nostrAvailable)
#expect(offline.connectionState == .offline)
}
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
func displayNameAndOfflineIconUseDerivedState() {
let peerID = PeerID(str: "fedcba9876543210")
let noiseKey = Data((32..<64).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
#expect(peer.displayName == String(peerID.id.prefix(8)))
#expect(peer.statusIcon == "🌙")
}
@Test("Mutual offline peers show Nostr icon")
func mutualFavoriteOfflinePeerShowsNostrIcon() {
let peerID = PeerID(str: "0011223344556677")
let noiseKey = Data((64..<96).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
#expect(peer.statusIcon == "🌐")
#expect(peer.isFavorite)
#expect(peer.isMutualFavorite)
#expect(peer.theyFavoritedUs)
}
@Test("Equality is based only on peer ID")
func equalityUsesPeerIDOnly() {
let peerID = PeerID(str: "8899aabbccddeeff")
let first = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 1, count: 32),
nickname: "First",
isConnected: false,
isReachable: false
)
let second = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 2, count: 32),
nickname: "Second",
isConnected: true,
isReachable: true
)
#expect(first == second)
}
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
FavoriteRelationship(
peerNoisePublicKey: Data(repeating: 7, count: 32),
peerNostrPublicKey: "npub1example",
peerNickname: "Peer",
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
}
}
@@ -1,229 +0,0 @@
//
// ChatViewModelDeliveryStatusTests.swift
// bitchatTests
//
// Tests for ChatViewModel delivery status state machine.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Delivery Status Tests
struct ChatViewModelDeliveryStatusTests {
// MARK: - Status Transition Tests
@Test @MainActor
func deliveryStatus_noDowngrade_readToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-1"
// Setup: create a message with .read status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .read(by: "Peer", at: Date())
)
viewModel.privateChats[peerID] = [message]
// Action: try to downgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should remain .read (no downgrade)
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_sentToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-2"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should be .delivered
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .delivered = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_deliveredToRead() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-3"
// Setup: create a message with .delivered status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .read
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Read Receipt Handling
@Test @MainActor
func didReceiveReadReceipt_updatesMessageStatus() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-4"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: receive read receipt
let receipt = ReadReceipt(
originalMessageID: messageID,
readerID: peerID,
readerNickname: "Peer"
)
viewModel.didReceiveReadReceipt(receipt)
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Public Timeline Status Tests
@Test @MainActor
func deliveryStatus_publicTimeline_updatesCorrectly() async {
let (viewModel, _) = makeTestableViewModel()
let messageID = "public-msg-1"
// Setup: add a message to public timeline with .sending status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Public message",
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sending
)
viewModel.messages.append(message)
// Action: update to .sent
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
// Assert
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
#expect({
if case .sent = updatedMessage?.deliveryStatus { return true }
return false
}())
}
// MARK: - Status Rank Tests (for deduplication)
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
]
// Verify each status has a logical progression
// This is more of a documentation test to ensure the ranking logic is understood
for (index, status) in statuses.enumerated() {
switch status {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
}
}
}
}
+18 -875
View File
@@ -8,12 +8,6 @@
import Testing import Testing
import Foundation import Foundation
import Combine import Combine
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -71,22 +65,6 @@ struct ChatViewModelPrivateChatExtensionTests {
// Check MockTransport implementation... it might need update or verification // Check MockTransport implementation... it might need update or verification
} }
@Test @MainActor
func sendPrivateMessage_unreachable_setsFailedStatus() async {
let (viewModel, _) = makeTestableViewModel()
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
let peerID = PeerID(str: validHex)
viewModel.sendPrivateMessage("Hello", to: peerID)
#expect(viewModel.privateChats[peerID]?.count == 1)
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
#expect({
if case .failed = status { return true }
return false
}())
}
@Test @MainActor @Test @MainActor
func handlePrivateMessage_storesMessage() async { func handlePrivateMessage_storesMessage() async {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
@@ -271,872 +249,37 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws { func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
LocationChannelManager.shared.select(channel)
defer { LocationChannelManager.shared.select(.mesh) }
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let signer = try NostrIdentity.generate() var event = NostrEvent(
let event = NostrEvent( pubkey: "pub1",
pubkey: signer.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Hello Geo" content: "Hello Geo"
) )
let signed = try event.sign(with: signer.schnorrSigningKey()) event.id = "evt1"
viewModel.handleNostrEvent(signed) event.sig = "sig"
let didAppend = await TestHelpers.waitUntil({ viewModel.handleNostrEvent(event)
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
#expect(didAppend)
}
@Test @MainActor
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Self echo"
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
// Allow async processing
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Self echo" }) // Check timeline
} // This depends on `handlePublicMessage` being called and updating `messages`
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
// And we are in the correct channel...
@Test @MainActor // However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
func handleNostrEvent_skipsBlockedSender() async throws { // Let's verify if the message appears.
let (viewModel, _) = makeTestableViewModel() // Note: `handleNostrEvent` logic was refactored.
let geohash = "u4pruydq" // The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
let blockedIdentity = try NostrIdentity.generate()
let blockedPubkey = blockedIdentity.publicKeyHex
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) // We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
let event = NostrEvent(
pubkey: blockedPubkey,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Blocked"
)
let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.deduplicationService.recordNostrEvent("evt-cache")
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
}
@Test @MainActor
func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(viewModel.messages.isEmpty)
}
@Test @MainActor
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-read"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .delivered(to: "Friend", at: Date())
)
]
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-private"
let convKey = PeerID(nostr_: sender.publicKeyHex)
let content = try privateMessageContent(
text: "Hello from gift wrap",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
timeout: 0.5
)
#expect(didStore)
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-blocked"
let convKey = PeerID(nostr_: sender.publicKeyHex)
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
let content = try privateMessageContent(
text: "Blocked",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Alice"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsHex() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "ab", count: 32)
let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrHex,
peerNickname: "Bob"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
}
@Test @MainActor
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Before"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
viewModel.handleFavoriteNotification(
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
from: identity.publicKeyHex
)
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
#expect(relationship?.peerNickname == "Alice")
#expect(relationship?.peerNostrPublicKey == identity.npub)
#expect(relationship?.isFavorite == true)
}
@Test @MainActor
func geohashDMHelpers_exposeMappingAndDisplayName() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "cd", count: 32)
let convKey = PeerID(nostr_: nostrHex)
viewModel.geoNicknames[nostrHex] = "Alice"
viewModel.startGeohashDM(withPubkeyHex: nostrHex)
#expect(viewModel.selectedPrivateChatPeer == convKey)
#expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
#expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
#expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
} }
} }
// MARK: - Geohash Queue Tests
struct ChatViewModelGeohashQueueTests {
@Test @MainActor
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.addGeohashOnlySystemMessage("Queued system")
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(viewModel.messages.contains { $0.content == "Queued system" })
}
}
// MARK: - GeoDM Tests
struct ChatViewModelGeoDMTests {
@Test @MainActor
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
let messageID = "pm-1"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let convKey = PeerID(nostr_: senderPubkey)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func sendGeohashDM_requiresActiveLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.messages.count == 1)
#expect(viewModel.messages.last?.sender == "system")
}
@Test @MainActor
func sendGeohashDM_missingRecipientMapping_marksFailed() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
}
@Test @MainActor
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
let convKey = PeerID(nostr_: recipientHex)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.nostrKeyMapping[convKey] = recipientHex
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
let convKey = PeerID(nostr_: senderPubkey)
let messageID = "pm-viewing"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.selectedPrivateChatPeer = convKey
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: identity,
messageTimestamp: Date()
)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
#expect(viewModel.sentReadReceipts.contains(messageID))
#expect(!viewModel.unreadPrivateMessages.contains(convKey))
}
}
struct ChatViewModelMediaTransferTests {
@Test @MainActor
func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
let transferID = "transfer-1"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))
viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))
viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
#expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(viewModel.transferIdToMessageIDs[transferID] == nil)
}
@Test @MainActor
func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
let transferID = "transfer-2"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))
#expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
}
@Test @MainActor
func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendImage_outsideAllowedContextRunsCleanup() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
var cleanupCalled = false
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
cleanupCalled = true
}
#expect(cleanupCalled)
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice payload".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 0.5)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
}
@Test @MainActor
func sendImage_privateChatProcessesAndTransfersImage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
let sourceURL = try makeTemporaryImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
}
@Test @MainActor
func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: url)
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 2.0)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
}
@Test @MainActor
func clearTransferMapping_promotesQueuedTransferForSameID() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")
viewModel.clearTransferMapping(for: "first")
#expect(viewModel.messageIDToTransferId["first"] == nil)
#expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
#expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
}
@Test @MainActor
func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
let fileName = "cancel-\(UUID().uuidString).m4a"
let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
try Data("cancel me".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "cancel-msg",
sender: viewModel.nickname,
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
viewModel.cancelMediaSend(messageID: message.id)
#expect(transport.cancelledTransfers == ["transfer-cancel"])
#expect(viewModel.privateChats[peerID] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
let fileName = "delete-\(UUID().uuidString).jpg"
let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
try Data("image bytes".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "delete-msg",
sender: viewModel.nickname,
content: "[image] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
viewModel.deleteMediaMessage(messageID: message.id)
#expect(viewModel.privateChats[peerID] == nil)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func makeTransferID_isPrefixedByMessageIDAndUnique() async {
let (viewModel, _) = makeTestableViewModel()
let first = viewModel.makeTransferID(messageID: "base")
let second = viewModel.makeTransferID(messageID: "base")
#expect(first.hasPrefix("base-"))
#expect(second.hasPrefix("base-"))
#expect(first != second)
}
}
private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: type,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidAckContent
}
private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: text,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
@MainActor
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
}
private func isFailed(status: DeliveryStatus?) -> Bool {
if case .failed = status {
return true
}
return false
}
private func isDelivered(status: DeliveryStatus?) -> Bool {
if case .delivered = status {
return true
}
return false
}
private func isRead(status: DeliveryStatus?) -> Bool {
if case .read = status {
return true
}
return false
}
private func isSent(status: DeliveryStatus?) -> Bool {
if case .sent = status {
return true
}
return false
}
private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
if case .partiallyDelivered(let actualReached, let actualTotal) = status {
return actualReached == reached && actualTotal == total
}
return false
}
private enum ChatViewModelExtensionsTestError: Error {
case invalidAckContent
case invalidPrivateMessageContent
}
private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
).appendingPathComponent("files", isDirectory: true)
let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent(fileName)
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
let data = try makeImageData()
try data.write(to: url, options: .atomic)
return url
}
private func makeImageData() throws -> Data {
#if os(iOS)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
}
guard let data = image.pngData() else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#else
let image = NSImage(size: CGSize(width: 64, height: 64))
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
image.unlockFocus()
guard
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let data = bitmap.representation(using: .png, properties: [:])
else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#endif
}
@@ -1,149 +0,0 @@
//
// ChatViewModelRefactoringTests.swift
// bitchatTests
//
// Pinning tests to characterize ChatViewModel behavior before refactoring.
// These tests act as a safety net to ensure we don't break existing functionality.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
struct ChatViewModelRefactoringTests {
// Helper to setup the environment
@MainActor
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport, identityManager)
}
// MARK: - Command Processor Integration "Pinning"
@Test @MainActor
func command_msg_routesToTransport() async throws {
let (viewModel, transport, _) = makePinnedViewModel()
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
let peerID = PeerID(str: "0000000000000001")
transport.simulateConnect(peerID, nickname: "alice")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action: User types /msg command
viewModel.sendMessage("/msg @alice Hello Private World")
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
timeout: TestConstants.shortTimeout)
#expect(didSend)
// Assert:
// 1. Should NOT go to public transport
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
// 2. Should go to private transport logic
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
}
@Test @MainActor
func command_block_updatesIdentity() async throws {
let (viewModel, transport, identity) = makePinnedViewModel()
// Setup: Use simulateConnect
let peerID = PeerID(str: "0000000000000002")
// Mock the fingerprint so the block command finds it
transport.peerFingerprints[peerID] = "fingerprint_123"
transport.simulateConnect(peerID, nickname: "troll")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action
viewModel.sendMessage("/block @troll")
// Assert
// Verify identity manager was called to block "fingerprint_123"
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
timeout: TestConstants.shortTimeout)
#expect(didBlock)
}
// MARK: - Message Routing Logic
@Test @MainActor
func routing_incomingPrivateMessage_addsToPrivateChats() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_1")
// Setup
let message = BitchatMessage(
id: "msg_1",
sender: "bob",
content: "Secret",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderID,
mentions: nil
)
// Action: Simulate incoming private message
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
@Test @MainActor
func routing_incomingPublicMessage_addsToPublicTimeline() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_2")
// Action
viewModel.didReceivePublicMessage(
from: senderID,
nickname: "charlie",
content: "Public Hi",
timestamp: Date(),
messageID: "msg_2"
)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
}
+3 -191
View File
@@ -8,7 +8,6 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -115,44 +114,6 @@ struct ChatViewModelSendingTests {
} }
} }
// MARK: - Command Handling Tests
struct ChatViewModelCommandTests {
@Test @MainActor
func sendMessage_commandsNotSentToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
let commands = ["/nick bob", "/who", "/help", "/clear"]
for command in commands {
transport.resetRecordings()
viewModel.sendMessage(command)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(transport.sentMessages.isEmpty)
#expect(transport.sentPrivateMessages.isEmpty)
}
}
}
// MARK: - Timeline Cap Tests
struct ChatViewModelTimelineCapTests {
@Test @MainActor
func sendMessage_trimsTimelineToCap() async {
let (viewModel, _) = makeTestableViewModel()
let total = TransportConfig.meshTimelineCap + 5
for i in 0..<total {
viewModel.sendMessage("cap-msg-\(i)")
}
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
}
}
// MARK: - Message Receiving Tests // MARK: - Message Receiving Tests
struct ChatViewModelReceivingTests { struct ChatViewModelReceivingTests {
@@ -196,45 +157,10 @@ struct ChatViewModelReceivingTests {
messageID: "pub-001" messageID: "pub-001"
) )
let found = await TestHelpers.waitUntil({ // Give time for async Task and pipeline processing
viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" } try? await Task.sleep(nanoseconds: 500_000_000)
}, timeout: TestConstants.defaultTimeout)
#expect(found) #expect(viewModel.messages.contains { $0.content == "Public hello from Bob" })
}
}
// MARK: - Rate Limiting Tests
struct ChatViewModelRateLimitingTests {
@Test @MainActor
func handlePublicMessage_rateLimitsBurstBySender() async {
let (viewModel, _) = makeTestableViewModel()
let senderID = PeerID(str: "1122334455667788")
let now = Date()
for i in 0..<6 {
let message = BitchatMessage(
id: "rate-\(i)",
sender: "Spammer",
content: "rate-msg-\(i)",
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
)
viewModel.handlePublicMessage(message)
}
viewModel.publicMessagePipeline.flushIfNeeded()
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
#expect(burstMessages.count == 5)
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
} }
} }
@@ -332,94 +258,6 @@ struct ChatViewModelPrivateChatTests {
} }
} }
// MARK: - Private Chat Selection Tests
struct ChatViewModelPrivateChatSelectionTests {
@Test @MainActor
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-120)
let newer = Date().addingTimeInterval(-30)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.unreadPrivateMessages = [peerA, peerB]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
@Test @MainActor
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-200)
let newer = Date().addingTimeInterval(-20)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
}
// MARK: - Bluetooth State Tests // MARK: - Bluetooth State Tests
struct ChatViewModelBluetoothTests { struct ChatViewModelBluetoothTests {
@@ -471,37 +309,11 @@ struct ChatViewModelPanicTests {
// Set up some state // Set up some state
transport.connectedPeers.insert(PeerID(str: "PEER1")) transport.connectedPeers.insert(PeerID(str: "PEER1"))
viewModel.messages = [
BitchatMessage(
id: "panic-1",
sender: "Tester",
content: "Before",
timestamp: Date(),
isRelay: false
)
]
viewModel.privateChats[PeerID(str: "PEER1")] = [
BitchatMessage(
id: "pm-1",
sender: "Peer",
content: "Secret",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "PEER1")
)
]
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
viewModel.panicClearAllData() viewModel.panicClearAllData()
// After panic, emergency disconnect should be called // After panic, emergency disconnect should be called
#expect(transport.emergencyDisconnectCallCount == 1) #expect(transport.emergencyDisconnectCallCount == 1)
#expect(viewModel.messages.isEmpty)
#expect(viewModel.privateChats.isEmpty)
#expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil)
} }
} }
-178
View File
@@ -1,178 +0,0 @@
//
// ChatViewModelTorTests.swift
// bitchatTests
//
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
//
import Testing
import Foundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Tor Notification Handler Tests
struct ChatViewModelTorTests {
// MARK: - handleTorWillStart Tests
@Test @MainActor
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition: flag should start false
#expect(!viewModel.torStatusAnnounced)
// Action: simulate Tor starting notification
viewModel.handleTorWillStart()
// Wait for Task to complete
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set (torEnforced is true in tests)
#expect(viewModel.torStatusAnnounced)
}
@Test @MainActor
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: pre-set the flag
viewModel.torStatusAnnounced = true
// Switch to a geohash channel so messages would be visible
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action: call handler again
viewModel.handleTorWillStart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message added (flag was already true)
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorWillRestart Tests
@Test @MainActor
func handleTorWillRestart_setsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition
#expect(!viewModel.torRestartPending)
// Action
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert
#expect(viewModel.torRestartPending)
}
@Test @MainActor
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
let (viewModel, _) = makeTestableViewModel()
// Action: call handler (works regardless of channel)
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set
#expect(viewModel.torRestartPending)
}
// MARK: - handleTorDidBecomeReady Tests
@Test @MainActor
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: simulate restart pending state
viewModel.torRestartPending = true
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should clear pending flag
#expect(!viewModel.torRestartPending)
}
@Test @MainActor
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: not restarting, but initial ready not announced yet
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = false
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should set flag (torEnforced is true in tests)
#expect(viewModel.torInitialReadyAnnounced)
}
@Test @MainActor
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: already announced initial ready
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = true
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorPreferenceChanged Tests
@Test @MainActor
func handleTorPreferenceChanged_resetsAllFlags() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: set all flags
viewModel.torStatusAnnounced = true
viewModel.torInitialReadyAnnounced = true
viewModel.torRestartPending = true
// Action
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: all flags reset
#expect(!viewModel.torStatusAnnounced)
#expect(!viewModel.torInitialReadyAnnounced)
#expect(!viewModel.torRestartPending)
}
}
+4 -394
View File
@@ -1,15 +1,12 @@
import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite(.serialized)
struct CommandProcessorTests { struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
@MainActor @MainActor
@Test func slapNotFoundGrammar() { @Test func slapNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system") let result = processor.process("/slap @system")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -21,8 +18,7 @@ struct CommandProcessorTests {
@MainActor @MainActor
@Test func hugNotFoundGrammar() { @Test func hugNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system") let result = processor.process("/hug @system")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -34,8 +30,7 @@ struct CommandProcessorTests {
@MainActor @MainActor
@Test func slapUsageMessage() { @Test func slapUsageMessage() {
let identityManager = MockIdentityManager(MockKeychain()) let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap") let result = processor.process("/slap")
switch result { switch result {
case .error(let message): case .error(let message):
@@ -44,389 +39,4 @@ struct CommandProcessorTests {
Issue.record("Expected error result for usage message") Issue.record("Expected error result for usage message")
} }
} }
@MainActor
@Test func msgStartsPrivateChatAndSendsMessage() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/msg @alice hello there")
}
switch result {
case .success(let message):
#expect(message == "started private chat with alice")
default:
Issue.record("Expected success result")
}
#expect(context.startedPrivateChats == [peerID])
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == "hello there")
#expect(context.sentPrivateMessages.first?.peerID == peerID)
}
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: alice, bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
let bridge = NostrIdentityBridge(keychain: MockKeychain())
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(idBridge: bridge)
let geohash = "u4pruy"
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
context.visibleGeoParticipants = [
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
]
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = await withSelectedChannel(channel) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let activePeer = PeerID(str: "active")
let otherPeer = PeerID(str: "other")
context.selectedPrivateChatPeer = activePeer
context.privateChats = [
activePeer: [makeMessage(sender: "alice", content: "secret")],
otherPeer: [makeMessage(sender: "bob", content: "keep")]
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.privateChats[activePeer] == [])
#expect(context.privateChats[otherPeer]?.count == 1)
}
@MainActor
@Test func clearInPublicChatClearsTimeline() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.clearCurrentPublicTimelineCallCount == 1)
}
@MainActor
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
context.nicknameToPeerID["bob"] = peerID
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/hug @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
}
@MainActor
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/slap @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
}
@MainActor
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerNicknames[peerID] = "bob"
transport.peerFingerprints[peerID] = "fp-bob"
context.blockedUsers = ["fp-bob"]
context.visibleGeoParticipants = [
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
]
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/block")
}
switch result {
case .success(let message):
#expect(message == "blocked peers: bob | geohash blocks: carol")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerFingerprints[peerID] = "fp-bob"
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block @bob")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock bob")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked bob")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
}
@MainActor
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block carol")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock @carol")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(
contextProvider: MockCommandContextProvider(),
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel) {
processor.process("/fav alice")
}
switch result {
case .error(let message):
#expect(message == "favorites are only for mesh peers in #mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
await setSelectedChannel(channel)
do {
let result = try work()
await setSelectedChannel(originalChannel)
return result
} catch {
await setSelectedChannel(originalChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(_ channel: ChannelID) async {
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
return
}
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
private func makeMessage(sender: String, content: String) -> BitchatMessage {
BitchatMessage(
sender: sender,
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false
)
}
}
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let idBridge: NostrIdentityBridge
var nicknameToPeerID: [String: PeerID] = [:]
var visibleGeoParticipants: [CommandGeoParticipant] = []
var displayNameToNostrPubkey: [String: String] = [:]
private(set) var startedPrivateChats: [PeerID] = []
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
private(set) var clearCurrentPublicTimelineCallCount = 0
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
self.nickname = nickname
self.idBridge = idBridge
}
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeoParticipants
}
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
displayNameToNostrPubkey[displayName]
}
func startPrivateChat(with peerID: PeerID) {
startedPrivateChats.append(peerID)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
func clearCurrentPublicTimeline() {
clearCurrentPublicTimelineCallCount += 1
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
func addPublicSystemMessage(_ content: String) {
publicSystemMessages.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
} }
@@ -9,7 +9,6 @@
import Testing import Testing
import CryptoKit import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
@testable import bitchat @testable import bitchat
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
@@ -8,7 +8,6 @@
import Testing import Testing
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
@testable import bitchat @testable import bitchat
struct PublicChatE2ETests { struct PublicChatE2ETests {
@@ -1,67 +0,0 @@
import Testing
import Foundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
private func makeTemporaryFileURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(name)
}
#if os(iOS)
private func makePlatformImage(size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
#else
private func makePlatformImage(size: CGSize) -> NSImage {
let image = NSImage(size: size)
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
image.unlockFocus()
return image
}
#endif
struct ImageUtilsTests {
@Test
func processImage_rejectsOversizedSourceFile() throws {
let url = makeTemporaryFileURL("image-too-large.bin")
try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_rejectsInvalidImageData() throws {
let url = makeTemporaryFileURL("image-invalid.bin")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_writesCompressedJpeg() throws {
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
defer { try? FileManager.default.removeItem(at: outputURL) }
let data = try Data(contentsOf: outputURL)
#expect(outputURL.pathExtension.lowercased() == "jpg")
#expect(data.starts(with: Data([0xFF, 0xD8])))
#expect(data.count > 0)
}
}

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