mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 22:05:21 +00:00
Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41b7f5171e | ||
|
|
3eb4f2bd72 | ||
|
|
193cfdc06a | ||
|
|
ffa0d7aa4f | ||
|
|
9e84f5e822 | ||
|
|
df36b19afe | ||
|
|
ab0da61533 | ||
|
|
3be8fbf1c4 | ||
|
|
764f016d17 | ||
|
|
a6cb8872fb | ||
|
|
3daf02732a | ||
|
|
0d1cdc7644 | ||
|
|
43eef0d49c | ||
|
|
bafa461f26 | ||
|
|
602d2316ef | ||
|
|
c60eff2c11 | ||
|
|
4cfcefcda6 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 |
@@ -5,14 +5,23 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Run Swift Tests
|
name: Run Swift Tests (${{ matrix.name }})
|
||||||
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
|
||||||
@@ -20,8 +29,14 @@ jobs:
|
|||||||
- name: Set up Swift
|
- name: Set up Swift
|
||||||
uses: swift-actions/setup-swift@v2
|
uses: swift-actions/setup-swift@v2
|
||||||
|
|
||||||
- name: Build the package
|
- name: Cache build artifacts
|
||||||
run: swift build
|
uses: actions/cache@v4
|
||||||
|
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
|
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ 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
|
||||||
|
|||||||
+7
-1
@@ -17,6 +17,7 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.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")
|
||||||
],
|
],
|
||||||
@@ -25,6 +26,7 @@ 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: "Arti")
|
||||||
],
|
],
|
||||||
@@ -32,6 +34,7 @@ let package = Package(
|
|||||||
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",
|
||||||
@@ -43,7 +46,10 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: [
|
||||||
|
"bitchat",
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation")
|
||||||
|
],
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
|
|||||||
Generated
+26
-4
@@ -10,6 +10,8 @@
|
|||||||
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 */; };
|
||||||
@@ -156,6 +158,7 @@
|
|||||||
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 */ = {
|
||||||
@@ -164,6 +167,7 @@
|
|||||||
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 */
|
||||||
@@ -223,6 +227,7 @@
|
|||||||
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 */;
|
||||||
@@ -303,6 +308,7 @@
|
|||||||
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 */;
|
||||||
@@ -344,6 +350,7 @@
|
|||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||||
|
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -548,6 +555,7 @@
|
|||||||
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;
|
||||||
@@ -558,7 +566,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -608,6 +616,7 @@
|
|||||||
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;
|
||||||
@@ -618,7 +627,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
MARKETING_VERSION = 1.5.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -654,7 +663,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
MARKETING_VERSION = 1.5.1;
|
||||||
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;
|
||||||
@@ -746,7 +755,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
MARKETING_VERSION = 1.5.1;
|
||||||
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;
|
||||||
@@ -907,6 +916,10 @@
|
|||||||
/* 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;
|
||||||
@@ -934,6 +947,15 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SharedContentKind: String, Sendable, Equatable {
|
||||||
|
case text
|
||||||
|
case url
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
|
case background
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||||
|
case willStart
|
||||||
|
case willRestart
|
||||||
|
case didBecomeReady
|
||||||
|
case preferenceChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppEvent: Sendable, Equatable {
|
||||||
|
case launched
|
||||||
|
case startupCompleted
|
||||||
|
case scenePhaseChanged(RuntimeScenePhase)
|
||||||
|
case openedURL(String)
|
||||||
|
case sharedContentAccepted(SharedContentKind)
|
||||||
|
case notificationOpened(peerID: PeerID?)
|
||||||
|
case deepLinkOpened(String)
|
||||||
|
case torLifecycleChanged(TorLifecycleEvent)
|
||||||
|
case nostrRelayConnectionChanged(Bool)
|
||||||
|
case terminationRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AppEventStream {
|
||||||
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
func stream() -> AsyncStream<AppEvent> {
|
||||||
|
let id = UUID()
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuations[id] = continuation
|
||||||
|
continuation.onTermination = { [id] _ in
|
||||||
|
Task {
|
||||||
|
await self.removeContinuation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(_ event: AppEvent) {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuations.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeContinuation(_ id: UUID) {
|
||||||
|
continuations.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PeerHandle: Sendable, Identifiable {
|
||||||
|
let id: String
|
||||||
|
let routingPeerID: PeerID
|
||||||
|
let displayName: String?
|
||||||
|
let noisePublicKeyHex: String?
|
||||||
|
let nostrPublicKey: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Equatable {
|
||||||
|
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Hashable {
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConversationID: Hashable, Sendable {
|
||||||
|
case mesh
|
||||||
|
case geohash(String)
|
||||||
|
case direct(PeerHandle)
|
||||||
|
|
||||||
|
init(channelID: ChannelID) {
|
||||||
|
switch channelID {
|
||||||
|
case .mesh:
|
||||||
|
self = .mesh
|
||||||
|
case .location(let channel):
|
||||||
|
self = .geohash(channel.geohash.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class IdentityResolver {
|
||||||
|
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
|
||||||
|
private var handlesByNoiseKey: [String: PeerHandle] = [:]
|
||||||
|
private var handlesByNostrKey: [String: PeerHandle] = [:]
|
||||||
|
|
||||||
|
func register(peers: [BitchatPeer]) {
|
||||||
|
for peer in peers {
|
||||||
|
_ = register(peer: peer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func register(peer: BitchatPeer) -> PeerHandle {
|
||||||
|
let handle = buildHandle(
|
||||||
|
routingPeerID: peer.peerID,
|
||||||
|
displayName: peer.displayName,
|
||||||
|
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
|
||||||
|
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
|
||||||
|
)
|
||||||
|
cache(handle)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
|
||||||
|
if let handle = handlesByRoutingPeerID[peerID] {
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
let handle = buildHandle(
|
||||||
|
routingPeerID: peerID,
|
||||||
|
displayName: displayName,
|
||||||
|
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
|
||||||
|
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
|
||||||
|
)
|
||||||
|
cache(handle)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildHandle(
|
||||||
|
routingPeerID: PeerID,
|
||||||
|
displayName: String?,
|
||||||
|
noisePublicKeyHex: String?,
|
||||||
|
nostrPublicKey: String?
|
||||||
|
) -> PeerHandle {
|
||||||
|
let canonicalID: String
|
||||||
|
if let noisePublicKeyHex {
|
||||||
|
canonicalID = "noise:\(noisePublicKeyHex)"
|
||||||
|
} else if let nostrPublicKey {
|
||||||
|
canonicalID = "nostr:\(nostrPublicKey)"
|
||||||
|
} else {
|
||||||
|
canonicalID = "mesh:\(routingPeerID.id)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalizedDisplayName: String?
|
||||||
|
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
normalizedDisplayName = displayName
|
||||||
|
} else {
|
||||||
|
normalizedDisplayName = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return PeerHandle(
|
||||||
|
id: canonicalID,
|
||||||
|
routingPeerID: routingPeerID,
|
||||||
|
displayName: normalizedDisplayName,
|
||||||
|
noisePublicKeyHex: noisePublicKeyHex,
|
||||||
|
nostrPublicKey: nostrPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
|
||||||
|
guard let nostrPublicKey else { return nil }
|
||||||
|
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cache(_ handle: PeerHandle) {
|
||||||
|
handlesByRoutingPeerID[handle.routingPeerID] = handle
|
||||||
|
if let noisePublicKeyHex = handle.noisePublicKeyHex {
|
||||||
|
handlesByNoiseKey[noisePublicKeyHex] = handle
|
||||||
|
}
|
||||||
|
if let nostrPublicKey = handle.nostrPublicKey {
|
||||||
|
handlesByNostrKey[nostrPublicKey] = handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConversationStore: ObservableObject {
|
||||||
|
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||||
|
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||||
|
@Published private(set) var selectedConversationID: ConversationID = .mesh
|
||||||
|
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||||
|
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
|
||||||
|
|
||||||
|
func setActiveChannel(_ channelID: ChannelID) {
|
||||||
|
activeChannel = channelID
|
||||||
|
if selectedPrivatePeerID == nil {
|
||||||
|
selectedConversationID = ConversationID(channelID: channelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedPeerID(
|
||||||
|
_ peerID: PeerID?,
|
||||||
|
activeChannel: ChannelID,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) {
|
||||||
|
self.activeChannel = activeChannel
|
||||||
|
selectedPrivatePeerID = peerID
|
||||||
|
|
||||||
|
if let peerID {
|
||||||
|
selectedConversationID = directConversationID(
|
||||||
|
for: peerID,
|
||||||
|
identityResolver: identityResolver
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
selectedConversationID = ConversationID(channelID: activeChannel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
|
||||||
|
messagesByConversation[conversationID] = normalized(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
|
||||||
|
replaceMessages(messages, for: ConversationID(channelID: channelID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
|
||||||
|
setActiveChannel(activeChannel)
|
||||||
|
replaceMessages(messages, for: activeChannel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
|
||||||
|
messagesByConversation[conversationID] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func directMessages(
|
||||||
|
for peerID: PeerID,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) -> [BitchatMessage] {
|
||||||
|
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
|
||||||
|
}
|
||||||
|
|
||||||
|
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
|
||||||
|
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
for (conversationID, handle) in directHandlesByConversation {
|
||||||
|
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return messagesByPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func unreadDirectPeerIDs() -> Set<PeerID> {
|
||||||
|
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
|
||||||
|
guard case .direct(let handle) = conversationID else { return }
|
||||||
|
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchronizeSelection(
|
||||||
|
activeChannel: ChannelID,
|
||||||
|
selectedPeerID: PeerID?,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) {
|
||||||
|
setSelectedPeerID(
|
||||||
|
selectedPeerID,
|
||||||
|
activeChannel: activeChannel,
|
||||||
|
identityResolver: identityResolver
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func synchronizePrivateChats(
|
||||||
|
_ privateChats: [PeerID: [BitchatMessage]],
|
||||||
|
unreadPeerIDs: Set<PeerID>,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) {
|
||||||
|
var liveConversations = Set<ConversationID>()
|
||||||
|
|
||||||
|
for (peerID, messages) in privateChats {
|
||||||
|
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
|
||||||
|
let conversationID = ConversationID.direct(handle)
|
||||||
|
liveConversations.insert(conversationID)
|
||||||
|
directHandlesByConversation[conversationID] = handle
|
||||||
|
messagesByConversation[conversationID] = normalized(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
|
||||||
|
guard case .direct = conversationID else { return false }
|
||||||
|
return !liveConversations.contains(conversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for conversationID in staleDirectConversations {
|
||||||
|
messagesByConversation.removeValue(forKey: conversationID)
|
||||||
|
unreadConversations.remove(conversationID)
|
||||||
|
directHandlesByConversation.removeValue(forKey: conversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
let publicUnread = unreadConversations.filter { conversationID in
|
||||||
|
switch conversationID {
|
||||||
|
case .mesh, .geohash:
|
||||||
|
return true
|
||||||
|
case .direct:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
|
||||||
|
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||||
|
result.insert(.direct(handle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(_ conversationID: ConversationID) {
|
||||||
|
unreadConversations.remove(conversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markRead(
|
||||||
|
peerID: PeerID,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) {
|
||||||
|
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
|
||||||
|
var uniqueMessages: [String: BitchatMessage] = [:]
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
uniqueMessages[message.id] = message
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueMessages.values.sorted { lhs, rhs in
|
||||||
|
if lhs.timestamp != rhs.timestamp {
|
||||||
|
return lhs.timestamp < rhs.timestamp
|
||||||
|
}
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func directConversationID(
|
||||||
|
for peerID: PeerID,
|
||||||
|
identityResolver: IdentityResolver
|
||||||
|
) -> ConversationID {
|
||||||
|
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||||
|
let conversationID = ConversationID.direct(handle)
|
||||||
|
directHandlesByConversation[conversationID] = handle
|
||||||
|
return conversationID
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppChromeModel: ObservableObject {
|
||||||
|
@Published private(set) var hasUnreadPrivateMessages = false
|
||||||
|
@Published var nickname: String
|
||||||
|
@Published var showingFingerprintFor: PeerID?
|
||||||
|
@Published var isAppInfoPresented = false
|
||||||
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
|
@Published var showBluetoothAlert = false
|
||||||
|
@Published var bluetoothAlertMessage = ""
|
||||||
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
@Published var showScreenshotPrivacyWarning = false
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.nickname = chatViewModel.nickname
|
||||||
|
|
||||||
|
bind(privateInboxModel: privateInboxModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldSuppressScreenshotNotification: Bool {
|
||||||
|
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
self.nickname = nickname
|
||||||
|
if chatViewModel.nickname != nickname {
|
||||||
|
chatViewModel.nickname = nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndSaveNickname() {
|
||||||
|
chatViewModel.validateAndSaveNickname()
|
||||||
|
if nickname != chatViewModel.nickname {
|
||||||
|
nickname = chatViewModel.nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMostRelevantPrivateChat() {
|
||||||
|
chatViewModel.openMostRelevantPrivateChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showFingerprint(for peerID: PeerID) {
|
||||||
|
showingFingerprintFor = peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearFingerprint() {
|
||||||
|
showingFingerprintFor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAppInfo() {
|
||||||
|
isAppInfoPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerScreenshotPrivacyWarning() {
|
||||||
|
showScreenshotPrivacyWarning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicClearAllData() {
|
||||||
|
chatViewModel.panicClearAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||||
|
privateInboxModel.$unreadPeerIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] unreadPeerIDs in
|
||||||
|
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] nickname in
|
||||||
|
guard let self, self.nickname != nickname else { return }
|
||||||
|
self.nickname = nickname
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$showBluetoothAlert
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showBluetoothAlert)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothAlertMessage
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothAlertMessage)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothState)
|
||||||
|
|
||||||
|
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Tor
|
||||||
|
import UserNotifications
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppRuntime: ObservableObject {
|
||||||
|
let chatViewModel: ChatViewModel
|
||||||
|
let events = AppEventStream()
|
||||||
|
let conversationStore: ConversationStore
|
||||||
|
let peerIdentityStore: PeerIdentityStore
|
||||||
|
let locationPresenceStore: LocationPresenceStore
|
||||||
|
let publicChatModel: PublicChatModel
|
||||||
|
let privateInboxModel: PrivateInboxModel
|
||||||
|
let privateConversationModel: PrivateConversationModel
|
||||||
|
let verificationModel: VerificationModel
|
||||||
|
let conversationUIModel: ConversationUIModel
|
||||||
|
let locationChannelsModel: LocationChannelsModel
|
||||||
|
let peerListModel: PeerListModel
|
||||||
|
let appChromeModel: AppChromeModel
|
||||||
|
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
private var lastNostrRelayConnectedState = false
|
||||||
|
private var didHandleInitialNostrConnection = false
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var didHandleInitialActive = false
|
||||||
|
private var didEnterBackground = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(
|
||||||
|
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||||
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
|
) {
|
||||||
|
self.idBridge = idBridge
|
||||||
|
let identityResolver = IdentityResolver()
|
||||||
|
let conversationStore = ConversationStore()
|
||||||
|
let peerIdentityStore = PeerIdentityStore()
|
||||||
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
|
let locationManager = LocationChannelManager.shared
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
self.peerIdentityStore = peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore
|
||||||
|
self.chatViewModel = ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain),
|
||||||
|
conversationStore: conversationStore,
|
||||||
|
identityResolver: identityResolver,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore,
|
||||||
|
locationManager: locationManager
|
||||||
|
)
|
||||||
|
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
||||||
|
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||||
|
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
|
self.privateConversationModel = PrivateConversationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversationStore: conversationStore,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.verificationModel = VerificationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.conversationUIModel = ConversationUIModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
conversationStore: conversationStore
|
||||||
|
)
|
||||||
|
self.peerListModel = PeerListModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversationStore: conversationStore,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore
|
||||||
|
)
|
||||||
|
self.appChromeModel = AppChromeModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateInboxModel: self.privateInboxModel
|
||||||
|
)
|
||||||
|
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
|
bindRuntimeObservers()
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else {
|
||||||
|
checkForSharedContent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
|
announceInitialTorStatusIfNeeded()
|
||||||
|
|
||||||
|
Task(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||||
|
let npub = await MainActor.run {
|
||||||
|
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
record(.launched)
|
||||||
|
record(.startupCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenURL(_ url: URL) {
|
||||||
|
record(.openedURL(url.absoluteString))
|
||||||
|
|
||||||
|
if url.scheme == "bitchat", url.host == "share" {
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDidBecomeActiveNotification() {
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
func handleMacDidBecomeActiveNotification() {
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||||
|
switch newPhase {
|
||||||
|
case .background:
|
||||||
|
record(.scenePhaseChanged(.background))
|
||||||
|
TorManager.shared.setAppForeground(false)
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
chatViewModel.endGeohashSampling()
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
didEnterBackground = true
|
||||||
|
|
||||||
|
case .active:
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.meshService.startServices()
|
||||||
|
TorManager.shared.setAppForeground(true)
|
||||||
|
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||||
|
|
||||||
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
didHandleInitialActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
didEnterBackground = false
|
||||||
|
|
||||||
|
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||||
|
Task.detached {
|
||||||
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
|
await MainActor.run {
|
||||||
|
TorURLSession.shared.rebuild()
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
case .inactive:
|
||||||
|
record(.scenePhaseChanged(.inactive))
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func applicationWillTerminate() {
|
||||||
|
record(.terminationRequested)
|
||||||
|
chatViewModel.applicationWillTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
record(.notificationOpened(peerID: peerID))
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||||
|
record(.deepLinkOpened(deepLink))
|
||||||
|
openExternalURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationOptions(
|
||||||
|
forNotificationIdentifier identifier: String,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
if conversationStore.selectedPrivatePeerID == peerID {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deepLink = userInfo["deeplink"] as? String,
|
||||||
|
let geohash = deepLink.components(separatedBy: "/").last,
|
||||||
|
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
channel.geohash == geohash {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppRuntime {
|
||||||
|
func bindRuntimeObservers() {
|
||||||
|
NostrRelayManager.shared.$isConnected
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isConnected in
|
||||||
|
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
|
self?.chatViewModel.handleTorWillStart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notification in
|
||||||
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForSharedContent() {
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||||
|
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||||
|
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||||
|
|
||||||
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
|
|
||||||
|
switch contentKind {
|
||||||
|
case .url:
|
||||||
|
if let data = sharedContent.data(using: .utf8),
|
||||||
|
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||||
|
let url = urlData["url"] {
|
||||||
|
chatViewModel.sendMessage(url)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
case .text:
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
record(.sharedContentAccepted(contentKind))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||||
|
record(.nostrRelayConnectionChanged(isConnected))
|
||||||
|
|
||||||
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
|
guard started, becameConnected else { return }
|
||||||
|
|
||||||
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
|
didHandleInitialNostrConnection = true
|
||||||
|
|
||||||
|
if !chatViewModel.nostrHandlersSetup {
|
||||||
|
chatViewModel.setupNostrMessageHandling()
|
||||||
|
chatViewModel.nostrHandlersSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isInitialConnection else { return }
|
||||||
|
|
||||||
|
chatViewModel.resubscribeCurrentGeohash()
|
||||||
|
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||||
|
}
|
||||||
|
|
||||||
|
func announceInitialTorStatusIfNeeded() {
|
||||||
|
if TorManager.shared.torEnforced &&
|
||||||
|
!chatViewModel.torStatusAnnounced &&
|
||||||
|
TorManager.shared.isAutoStartAllowed() {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||||
|
)
|
||||||
|
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScreenshotCaptured() {
|
||||||
|
if appChromeModel.isLocationChannelsSheetPresented {
|
||||||
|
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if appChromeModel.isAppInfoPresented {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openExternalURL(_ url: URL) {
|
||||||
|
#if os(iOS)
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
#else
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ event: AppEvent) {
|
||||||
|
Task {
|
||||||
|
await events.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConversationUIModel: ObservableObject {
|
||||||
|
@Published private(set) var showAutocomplete = false
|
||||||
|
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var isBatchingPublic = false
|
||||||
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let privateConversationModel: PrivateConversationModel
|
||||||
|
private let conversationStore: ConversationStore
|
||||||
|
private var activeChannel: ChannelID
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
conversationStore: ConversationStore
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.privateConversationModel = privateConversationModel
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
self.activeChannel = conversationStore.activeChannel
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||||
|
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||||
|
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||||
|
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||||
|
chatViewModel.currentColorScheme = colorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(_ message: String) {
|
||||||
|
chatViewModel.sendMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCurrentConversation() {
|
||||||
|
chatViewModel.sendMessage("/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHug(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/hug @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSlap(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/slap @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func block(peerID: PeerID?, displayName: String?) {
|
||||||
|
guard let displayName else { return }
|
||||||
|
|
||||||
|
if let peerID, peerID.isGeoChat,
|
||||||
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
|
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage("/block \(displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||||
|
chatViewModel.completeNickname(nickname, in: &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||||
|
message.mediaAttachment(for: currentNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||||
|
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||||
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
|
return chatViewModel.geohashDisplayName(for: peerID)
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func processSelectedImage(_ image: UIImage?) {
|
||||||
|
chatViewModel.processThenSendImage(image)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func processSelectedImage(from url: URL?) {
|
||||||
|
#if os(macOS)
|
||||||
|
chatViewModel.processThenSendImage(from: url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceNote(at url: URL) {
|
||||||
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMediaSend(messageID: String) {
|
||||||
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
chatViewModel.$showAutocomplete
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showAutocomplete)
|
||||||
|
|
||||||
|
chatViewModel.$autocompleteSuggestions
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$autocompleteSuggestions)
|
||||||
|
|
||||||
|
chatViewModel.$isBatchingPublic
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
|
conversationStore.$activeChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
self?.activeChannel = channel
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshComputedState() {
|
||||||
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
canSendMediaInCurrentContext = true
|
||||||
|
case .location:
|
||||||
|
canSendMediaInCurrentContext = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationChannelsModel: ObservableObject {
|
||||||
|
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel]
|
||||||
|
@Published private(set) var selectedChannel: ChannelID
|
||||||
|
@Published private(set) var teleported: Bool
|
||||||
|
@Published private(set) var bookmarks: [String]
|
||||||
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
|
||||||
|
private let manager: LocationChannelManager
|
||||||
|
private let network: NetworkActivationService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
manager: LocationChannelManager? = nil,
|
||||||
|
network: NetworkActivationService? = nil
|
||||||
|
) {
|
||||||
|
let manager = manager ?? .shared
|
||||||
|
let network = network ?? .shared
|
||||||
|
|
||||||
|
self.manager = manager
|
||||||
|
self.network = network
|
||||||
|
self.permissionState = manager.permissionState
|
||||||
|
self.availableChannels = manager.availableChannels
|
||||||
|
self.selectedChannel = manager.selectedChannel
|
||||||
|
self.teleported = manager.teleported
|
||||||
|
self.bookmarks = manager.bookmarks
|
||||||
|
self.bookmarkNames = manager.bookmarkNames
|
||||||
|
self.locationNames = manager.locationNames
|
||||||
|
self.userTorEnabled = network.userTorEnabled
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBuildingGeohash: String? {
|
||||||
|
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
|
guard case .location(let selected) = selectedChannel else { return false }
|
||||||
|
return selected == channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
manager.isBookmarked(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableLocationChannels() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableAndRefresh() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginLiveRefresh() {
|
||||||
|
manager.beginLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLiveRefresh() {
|
||||||
|
manager.endLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
manager.select(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
manager.markTeleported(for: geohash, flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleBookmark(_ geohash: String) {
|
||||||
|
manager.toggleBookmark(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||||
|
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
|
locationNames[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
network.setUserTorEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMeshChannelsIfNeeded() {
|
||||||
|
guard case .mesh = selectedChannel,
|
||||||
|
permissionState == .authorized,
|
||||||
|
availableChannels.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLocationChannel(for geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
guard (2...12).contains(normalized.count),
|
||||||
|
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||||
|
if !isRegional && !availableChannels.isEmpty {
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
}
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func teleport(to geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
manager.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$permissionState)
|
||||||
|
|
||||||
|
manager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$availableChannels)
|
||||||
|
|
||||||
|
manager.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedChannel)
|
||||||
|
|
||||||
|
manager.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$teleported)
|
||||||
|
|
||||||
|
manager.$bookmarks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarks)
|
||||||
|
|
||||||
|
manager.$bookmarkNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarkNames)
|
||||||
|
|
||||||
|
manager.$locationNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$locationNames)
|
||||||
|
|
||||||
|
network.$userTorEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$userTorEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
switch length {
|
||||||
|
case 0...2: return .region
|
||||||
|
case 3...4: return .province
|
||||||
|
case 5: return .city
|
||||||
|
case 6: return .neighborhood
|
||||||
|
case 7: return .block
|
||||||
|
case 8...12: return .building
|
||||||
|
default: return .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationPresenceStore: ObservableObject {
|
||||||
|
@Published private(set) var currentGeohash: String?
|
||||||
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
|
@Published private(set) var teleportedGeo: Set<String> = []
|
||||||
|
|
||||||
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
|
currentGeohash = geohash?.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
|
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
|
geoNicknames = Dictionary(
|
||||||
|
uniqueKeysWithValues: nicknames.map { key, value in
|
||||||
|
(key.lowercased(), value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoNicknames() {
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
|
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleportedGeo() {
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
currentGeohash = nil
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerIdentityStore: ObservableObject {
|
||||||
|
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||||
|
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||||
|
|
||||||
|
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||||
|
private(set) var selectedPrivateChatFingerprint: String?
|
||||||
|
|
||||||
|
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||||
|
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||||
|
|
||||||
|
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||||
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||||
|
stablePeerIDsByShortID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peerFingerprintsByPeerID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||||
|
} else {
|
||||||
|
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||||
|
peerFingerprintsByPeerID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func migrateFingerprintMapping(
|
||||||
|
from oldPeerID: PeerID,
|
||||||
|
to newPeerID: PeerID,
|
||||||
|
fallback: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||||
|
if selectedPrivateChatFingerprint == nil {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatusCache[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||||
|
encryptionStatusCache[peerID] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||||
|
if let peerID {
|
||||||
|
encryptionStatusCache.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatuses[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||||
|
if let status {
|
||||||
|
encryptionStatuses[peerID] = status
|
||||||
|
} else {
|
||||||
|
encryptionStatuses.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
invalidateEncryptionCache(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||||
|
encryptionStatuses = statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
|
verifiedFingerprints = fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||||
|
if verified {
|
||||||
|
verifiedFingerprints.insert(fingerprint)
|
||||||
|
} else {
|
||||||
|
verifiedFingerprints.remove(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(_ fingerprint: String) -> Bool {
|
||||||
|
verifiedFingerprints.contains(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
encryptionStatuses.removeAll()
|
||||||
|
verifiedFingerprints.removeAll()
|
||||||
|
peerFingerprintsByPeerID.removeAll()
|
||||||
|
selectedPrivateChatFingerprint = nil
|
||||||
|
stablePeerIDsByShortID.removeAll()
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MeshPeerRow: Identifiable, Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let hasUnread: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
let isFavorite: Bool
|
||||||
|
let isConnected: Bool
|
||||||
|
let isReachable: Bool
|
||||||
|
let isMutualFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let showsVerifiedBadgeWhenOffline: Bool
|
||||||
|
|
||||||
|
var id: String { peerID.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeohashPersonRow: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let isTeleported: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerListModel: ObservableObject {
|
||||||
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
|
@Published private(set) var renderID = ""
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversationStore: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private let locationPresenceStore: LocationPresenceStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversationStore: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
|
locationPresenceStore: LocationPresenceStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||||
|
self.allPeers = chatViewModel.allPeers
|
||||||
|
|
||||||
|
bind()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func participantCount(for geohash: String) -> Int {
|
||||||
|
chatViewModel.geohashParticipantCount(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.blockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.unblockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] peers in
|
||||||
|
self?.allPeers = peers
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationPresenceStore.$teleportedGeo
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversationStore.$unreadConversations
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() {
|
||||||
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
|
let meshRows = allPeers.map { peer in
|
||||||
|
let isMe = peer.peerID == myPeerID
|
||||||
|
let verifiedBadge: Bool
|
||||||
|
if !isMe && !peer.isConnected,
|
||||||
|
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||||
|
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||||
|
} else {
|
||||||
|
verifiedBadge = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeshPeerRow(
|
||||||
|
peerID: peer.peerID,
|
||||||
|
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||||
|
isMe: isMe,
|
||||||
|
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||||
|
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||||
|
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||||
|
isConnected: peer.isConnected,
|
||||||
|
isReachable: peer.isReachable,
|
||||||
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
|
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||||
|
guard !row.isMe else { return }
|
||||||
|
if row.isConnected {
|
||||||
|
counts.connected += 1
|
||||||
|
counts.reachable += 1
|
||||||
|
} else if row.isReachable {
|
||||||
|
counts.reachable += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
|
||||||
|
self.meshRows = meshRows
|
||||||
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
|
self.geohashPeople = geohashPeople
|
||||||
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
renderID = (
|
||||||
|
meshRows.map {
|
||||||
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
|
} +
|
||||||
|
geohashPeople.map {
|
||||||
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
}
|
||||||
|
).joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
|
let myHex = currentGeohashIdentityHex()
|
||||||
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|
||||||
|
return chatViewModel.visibleGeohashPeople().map { person in
|
||||||
|
let isMe = person.id == myHex
|
||||||
|
return GeohashPersonRow(
|
||||||
|
id: person.id,
|
||||||
|
displayName: person.displayName,
|
||||||
|
isMe: isMe,
|
||||||
|
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||||
|
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentGeohashIdentityHex() -> String? {
|
||||||
|
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity.publicKeyHex.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PrivateInboxModel: ObservableObject {
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
|
||||||
|
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
|
private let conversationStore: ConversationStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(conversationStore: ConversationStore) {
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
|
||||||
|
bind()
|
||||||
|
refreshMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
|
guard let peerID else { return [] }
|
||||||
|
return messagesByPeerID[peerID] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversationStore.$selectedPrivatePeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] peerID in
|
||||||
|
self?.selectedPeerID = peerID
|
||||||
|
self?.refreshMessages()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversationStore.$unreadConversations
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
|
||||||
|
self?.refreshMessages()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversationStore.$messagesByConversation
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshMessages()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||||
|
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshMessages() {
|
||||||
|
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
|
||||||
|
var peerIDs = Set(nextMessagesByPeerID.keys)
|
||||||
|
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
|
||||||
|
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
|
||||||
|
peerIDs.insert(selectedPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
|
||||||
|
nextMessagesByPeerID[peerID] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesByPeerID = nextMessagesByPeerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrivateConversationAvailability: Equatable {
|
||||||
|
case bluetoothConnected
|
||||||
|
case meshReachable
|
||||||
|
case nostrAvailable
|
||||||
|
case offline
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivateConversationHeaderState: Equatable {
|
||||||
|
let conversationPeerID: PeerID
|
||||||
|
let headerPeerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let availability: PrivateConversationAvailability
|
||||||
|
let isFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
|
var supportsFavoriteToggle: Bool {
|
||||||
|
!conversationPeerID.isGeoDM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PrivateConversationModel: ObservableObject {
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversationStore: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversationStore: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
let initialPeerID = conversationStore.selectedPrivatePeerID
|
||||||
|
self.selectedPeerID = initialPeerID
|
||||||
|
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openConversation(for peerID: PeerID) {
|
||||||
|
if peerID.isGeoChat {
|
||||||
|
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
|
} else {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endConversation() {
|
||||||
|
chatViewModel.endPrivateChat()
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavoriteForSelectedConversation() {
|
||||||
|
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||||
|
toggleFavorite(peerID: headerPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMessagesAsRead(from peerID: PeerID) {
|
||||||
|
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversationStore.$selectedPrivatePeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSelectedConversation() {
|
||||||
|
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||||
|
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||||
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
|
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
|
? nil
|
||||||
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|
||||||
|
return PrivateConversationHeaderState(
|
||||||
|
conversationPeerID: conversationPeerID,
|
||||||
|
headerPeerID: headerPeerID,
|
||||||
|
displayName: displayName,
|
||||||
|
availability: availability,
|
||||||
|
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||||
|
encryptionStatus: encryptionStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(
|
||||||
|
for conversationPeerID: PeerID,
|
||||||
|
headerPeerID: PeerID,
|
||||||
|
peer: BitchatPeer?
|
||||||
|
) -> String {
|
||||||
|
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
|
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||||
|
}
|
||||||
|
if let displayName = peer?.displayName {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||||
|
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||||
|
), !favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
if headerPeerID.id.count == 16 {
|
||||||
|
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
|
if let identity = candidates.first,
|
||||||
|
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let noiseKey = headerPeerID.noiseKey {
|
||||||
|
let fingerprint = noiseKey.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||||
|
if let connectionState = peer?.connectionState {
|
||||||
|
switch connectionState {
|
||||||
|
case .bluetoothConnected:
|
||||||
|
return .bluetoothConnected
|
||||||
|
case .meshReachable:
|
||||||
|
return .meshReachable
|
||||||
|
case .nostrAvailable:
|
||||||
|
return .nostrAvailable
|
||||||
|
case .offline:
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
|
return .meshReachable
|
||||||
|
}
|
||||||
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||||
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
favoriteStatus.isMutual {
|
||||||
|
return .nostrAvailable
|
||||||
|
}
|
||||||
|
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||||
|
return .bluetoothConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PublicChatModel: ObservableObject {
|
||||||
|
@Published private(set) var activeChannel: ChannelID
|
||||||
|
@Published private(set) var messages: [BitchatMessage] = []
|
||||||
|
|
||||||
|
private let conversationStore: ConversationStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(conversationStore: ConversationStore) {
|
||||||
|
self.activeChannel = conversationStore.activeChannel
|
||||||
|
self.conversationStore = conversationStore
|
||||||
|
|
||||||
|
bind()
|
||||||
|
refreshMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversationStore.$activeChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
self?.activeChannel = channel
|
||||||
|
self?.refreshMessages()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversationStore.$messagesByConversation
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshMessages()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshMessages() {
|
||||||
|
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FingerprintPresentationState: Equatable {
|
||||||
|
let statusPeerID: PeerID
|
||||||
|
let peerNickname: String
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let theirFingerprint: String?
|
||||||
|
let myFingerprint: String
|
||||||
|
let isVerified: Bool
|
||||||
|
|
||||||
|
var canToggleVerification: Bool {
|
||||||
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationScanOutcome: Equatable {
|
||||||
|
case requested(String)
|
||||||
|
case notFound
|
||||||
|
case invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class VerificationModel: ObservableObject {
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||||
|
|
||||||
|
bind(privateConversationModel: privateConversationModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func myQRString() -> String {
|
||||||
|
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
chatViewModel.beginQRVerification(with: qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
|
return .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||||
|
return .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return .requested(qr.nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.verifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unverifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.unverifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(peerID: PeerID) -> Bool {
|
||||||
|
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||||
|
return peerIdentityStore.isVerified(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||||
|
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||||
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
|
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||||
|
|
||||||
|
return FingerprintPresentationState(
|
||||||
|
statusPeerID: statusPeerID,
|
||||||
|
peerNickname: peerNickname,
|
||||||
|
encryptionStatus: encryptionStatus,
|
||||||
|
theirFingerprint: theirFingerprint,
|
||||||
|
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||||
|
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedPeerID)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||||
|
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||||
|
return peer.displayName
|
||||||
|
}
|
||||||
|
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if let data = peerID.noiseKey {
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||||
|
!favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
let fingerprint = data.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-199
@@ -6,7 +6,6 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Tor
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -14,119 +13,48 @@ import UserNotifications
|
|||||||
struct BitchatApp: App {
|
struct BitchatApp: App {
|
||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var runtime: AppRuntime
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
// Skip the very first .active-triggered Tor restart on cold launch
|
|
||||||
@State private var didHandleInitialActive: Bool = false
|
|
||||||
@State private var didEnterBackground: Bool = false
|
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
|
||||||
wrappedValue: ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(chatViewModel)
|
.environmentObject(runtime.publicChatModel)
|
||||||
|
.environmentObject(runtime.privateInboxModel)
|
||||||
|
.environmentObject(runtime.privateConversationModel)
|
||||||
|
.environmentObject(runtime.verificationModel)
|
||||||
|
.environmentObject(runtime.conversationUIModel)
|
||||||
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
|
.environmentObject(runtime.peerListModel)
|
||||||
|
.environmentObject(runtime.appChromeModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
appDelegate.runtime = runtime
|
||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
runtime.start()
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
|
||||||
}
|
|
||||||
|
|
||||||
appDelegate.chatViewModel = chatViewModel
|
|
||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
|
||||||
NetworkActivationService.shared.start()
|
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleURL(url)
|
runtime.handleOpenURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
switch newPhase {
|
runtime.handleScenePhaseChange(newPhase)
|
||||||
case .background:
|
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
|
||||||
// Always send Tor to dormant on background for a clean restart later.
|
|
||||||
TorManager.shared.setAppForeground(false)
|
|
||||||
TorManager.shared.goDormantOnBackground()
|
|
||||||
// Stop geohash sampling while backgrounded
|
|
||||||
Task { @MainActor in
|
|
||||||
chatViewModel.endGeohashSampling()
|
|
||||||
}
|
|
||||||
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
|
||||||
NostrRelayManager.shared.disconnect()
|
|
||||||
didEnterBackground = true
|
|
||||||
case .active:
|
|
||||||
// Restart services when becoming active
|
|
||||||
chatViewModel.meshService.startServices()
|
|
||||||
TorManager.shared.setAppForeground(true)
|
|
||||||
// On initial cold launch, Tor was just started in onAppear.
|
|
||||||
// Skip the deterministic restart the first time we become active.
|
|
||||||
if didHandleInitialActive && didEnterBackground {
|
|
||||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
didHandleInitialActive = true
|
|
||||||
}
|
|
||||||
didEnterBackground = false
|
|
||||||
if TorManager.shared.isAutoStartAllowed() {
|
|
||||||
Task.detached {
|
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
|
||||||
await MainActor.run {
|
|
||||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
|
||||||
TorURLSession.shared.rebuild()
|
|
||||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
|
||||||
NostrRelayManager.shared.resetAllConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkForSharedContent()
|
|
||||||
case .inactive:
|
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Check for shared content when app becomes active
|
runtime.handleDidBecomeActiveNotification()
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
// App became active
|
runtime.handleMacDidBecomeActiveNotification()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -135,66 +63,18 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
|
||||||
if url.scheme == "bitchat" && url.host == "share" {
|
|
||||||
// Handle shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkForSharedContent() {
|
|
||||||
// Check app group for shared content from extension
|
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process if shared within configured window
|
|
||||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
|
||||||
|
|
||||||
// Clear the shared content
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
// No need to force synchronize here
|
|
||||||
|
|
||||||
// Send the shared content immediately on the main queue
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if contentType == "url" {
|
|
||||||
// Try to parse as JSON first
|
|
||||||
if let data = sharedContent.data(using: .utf8),
|
|
||||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
|
||||||
let url = urlData["url"] {
|
|
||||||
// Send plain URL
|
|
||||||
self.chatViewModel.sendMessage(url)
|
|
||||||
} else {
|
|
||||||
// Fallback to simple URL
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -203,79 +83,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor in
|
||||||
if identifier.hasPrefix("private-") {
|
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||||
// Get peer ID from userInfo
|
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Handle deeplink (e.g., geohash activity)
|
|
||||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
|
||||||
#if os(iOS)
|
|
||||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
|
||||||
#else
|
|
||||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task {
|
||||||
if identifier.hasPrefix("private-") {
|
let options = await self.runtime?.presentationOptions(
|
||||||
// Get peer ID from userInfo
|
forNotificationIdentifier: identifier,
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
userInfo: userInfo
|
||||||
// Don't show notification if the private chat is already open
|
) ?? [.banner, .sound]
|
||||||
// Access main-actor-isolated property via Task
|
completionHandler(options)
|
||||||
Task { @MainActor in
|
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
|
||||||
let deep = userInfo["deeplink"] as? String,
|
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,39 @@ enum ImageUtilsError: Error {
|
|||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
try validateImageSource(at: url)
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
// Allow up to 10MB source images (will be scaled down)
|
|
||||||
guard fileSize <= 10 * 1024 * 1024 else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func validateImageSource(at url: URL) throws {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory.
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int,
|
||||||
|
fileSize > 0,
|
||||||
|
fileSize <= maxSourceImageBytes else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||||
|
CGImageSourceGetType(source) != nil else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -64,7 +73,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -142,7 +151,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -186,12 +195,17 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
let directory: URL
|
||||||
|
if let outputDirectory {
|
||||||
|
directory = outputDirectory
|
||||||
|
} else {
|
||||||
|
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
}
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ 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
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ 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.
|
||||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
actor VoiceRecorder {
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
case recorderInitializationFailed
|
||||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
@discardableResult
|
nonisolated
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
try queue.sync {
|
if recorder?.isRecording == true {
|
||||||
if recorder?.isRecording == true {
|
throw RecorderError.recordingInProgress
|
||||||
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(completion: @escaping (URL?) -> Void) {
|
func stopRecording() async -> URL? {
|
||||||
queue.async { [weak self] in
|
guard let recorder, recorder.isRecording else {
|
||||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
return currentURL
|
||||||
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() {
|
||||||
queue.async { [weak self] in
|
if let recorder, recorder.isRecording {
|
||||||
guard let self = self else { return }
|
recorder.stop()
|
||||||
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 {
|
||||||
// MARK: - Metering
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
|
|
||||||
func currentAveragePower() -> Float {
|
|
||||||
queue.sync {
|
|
||||||
recorder?.updateMeters()
|
|
||||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
||||||
}
|
}
|
||||||
|
recorder = nil
|
||||||
|
currentURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -201,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
// Log error but continue with empty cache
|
cache = IdentityCache()
|
||||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,16 +336,15 @@ 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 existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
if let previousClaimedNickname,
|
||||||
// Remove old nickname from index if changed
|
previousClaimedNickname != identity.claimedNickname {
|
||||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,4 +536,16 @@ 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] }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -2,6 +2,8 @@
|
|||||||
<!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>
|
||||||
@@ -37,12 +39,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>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<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>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage+Media.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
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,5 +1,6 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -384,6 +385,16 @@ 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
|
||||||
@@ -585,8 +596,9 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -861,6 +873,20 @@ 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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
|
|||||||
@@ -9,11 +9,13 @@
|
|||||||
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
|
||||||
@@ -23,7 +25,27 @@ 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
|
||||||
|
|
||||||
@@ -66,12 +88,7 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
let session = SecureNoiseSession(
|
let session = sessionFactory(peerID, .initiator)
|
||||||
peerID: peerID,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
sessions[peerID] = session
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -117,12 +134,7 @@ final class NoiseSessionManager {
|
|||||||
// Get or create session
|
// Get or create session
|
||||||
let session: NoiseSession
|
let session: NoiseSession
|
||||||
if shouldCreateNew {
|
if shouldCreateNew {
|
||||||
let newSession = SecureNoiseSession(
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
peerID: peerID,
|
|
||||||
role: .responder,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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 let sessionStartTime = Date()
|
private var 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,5 +77,9 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
func setMessageCountForTesting(_ count: UInt64) {
|
func setMessageCountForTesting(_ count: UInt64) {
|
||||||
messageCount = count
|
messageCount = count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSessionStartTimeForTesting(_ date: Date) {
|
||||||
|
sessionStartTime = date
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Base64URLCoding {
|
||||||
|
static func encode(_ data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ string: String) -> Data? {
|
||||||
|
var base64 = string
|
||||||
|
let padding = (4 - (base64.count % 4)) % 4
|
||||||
|
if padding > 0 {
|
||||||
|
base64 += String(repeating: "=", count: padding)
|
||||||
|
}
|
||||||
|
base64 = base64
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,39 +8,154 @@ 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 {
|
||||||
struct Entry: Hashable {
|
private final class CleanupState {
|
||||||
|
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 remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
private let dependencies: GeoRelayDirectoryDependencies
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
private let cleanupState: CleanupState
|
||||||
|
|
||||||
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()
|
||||||
registerObservers()
|
if dependencies.autoStart {
|
||||||
startRefreshTimer()
|
registerObservers()
|
||||||
prefetchIfNeeded()
|
startRefreshTimer()
|
||||||
|
prefetchIfNeeded()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
self.dependencies = dependencies
|
||||||
refreshTimer?.invalidate()
|
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||||
retryTask?.cancel()
|
entries = loadLocalEntries()
|
||||||
|
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.
|
||||||
@@ -83,13 +198,13 @@ final class GeoRelayDirectory {
|
|||||||
func prefetchIfNeeded(force: Bool = false) {
|
func prefetchIfNeeded(force: Bool = false) {
|
||||||
guard !isFetching else { return }
|
guard !isFetching else { return }
|
||||||
|
|
||||||
let now = Date()
|
let now = dependencies.now()
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||||
|
|
||||||
if !force {
|
if !force {
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||||
} else if last != .distantPast,
|
} else if last != .distantPast,
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
// Skip forced fetches if we just refreshed moments ago.
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -103,51 +218,73 @@ final class GeoRelayDirectory {
|
|||||||
isFetching = true
|
isFetching = true
|
||||||
|
|
||||||
let request = URLRequest(
|
let request = URLRequest(
|
||||||
url: remoteURL,
|
url: dependencies.remoteURL,
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||||
timeoutInterval: 15
|
timeoutInterval: 15
|
||||||
)
|
)
|
||||||
|
let awaitTorReady = dependencies.awaitTorReady
|
||||||
|
let fetchData = dependencies.makeFetchData()
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
Task { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
let outcome = await Self.fetchRemoteOutcome(
|
||||||
if !ready {
|
request: request,
|
||||||
await self.handleFetchFailure(.torNotReady)
|
awaitTorReady: awaitTorReady,
|
||||||
return
|
fetchData: fetchData
|
||||||
}
|
)
|
||||||
|
|
||||||
do {
|
switch outcome {
|
||||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
case .success(let parsed, let csv):
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||||
await self.handleFetchFailure(.invalidData)
|
case .torNotReady:
|
||||||
return
|
self.handleFetchFailure(.torNotReady)
|
||||||
}
|
case .invalidData:
|
||||||
|
self.handleFetchFailure(.invalidData)
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
case .network(let description):
|
||||||
guard !parsed.isEmpty else {
|
self.handleFetchFailure(.network(description))
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
await self.handleFetchFailure(.network(error))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let data = try await fetchData(request)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = Self.parseCSV(text)
|
||||||
|
guard !parsed.isEmpty else {
|
||||||
|
return .invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(entries: parsed, csv: text)
|
||||||
|
} catch {
|
||||||
|
return .network(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
private enum FetchFailure {
|
private enum FetchFailure {
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(Error)
|
case network(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
@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)
|
||||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
dependencies.userDefaults.set(dependencies.now(), 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
|
||||||
@@ -161,8 +298,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 error):
|
case .network(let errorDescription):
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||||
}
|
}
|
||||||
isFetching = false
|
isFetching = false
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
@@ -171,32 +308,34 @@ final class GeoRelayDirectory {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func scheduleRetry() {
|
private func scheduleRetry() {
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
retryAttempt = min(retryAttempt + 1, 10)
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
let base = dependencies.retryInitialSeconds
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
let maxDelay = dependencies.retryMaxSeconds
|
||||||
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()
|
||||||
retryTask = Task { [weak self] in
|
cleanupState.retryTask = Task { [weak self] in
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
guard let self else { return }
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
await self.dependencies.retrySleep(delay)
|
||||||
|
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() {
|
||||||
retryTask?.cancel()
|
cleanupState.retryTask?.cancel()
|
||||||
retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ text: String) {
|
||||||
guard let url = cacheURL() else { return }
|
guard let url = dependencies.cacheURL() else { return }
|
||||||
|
guard let data = text.data(using: .utf8) else { return }
|
||||||
do {
|
do {
|
||||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
try dependencies.writeData(data, url)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||||
}
|
}
|
||||||
@@ -205,22 +344,18 @@ 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 = cacheURL(),
|
if let cache = dependencies.cacheURL(),
|
||||||
let data = try? Data(contentsOf: cache),
|
let data = dependencies.readData(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 = [
|
let bundleCandidates = dependencies.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 }
|
|
||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = try? Data(contentsOf: url),
|
if let data = dependencies.readData(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 }
|
||||||
@@ -228,8 +363,8 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
// Try filesystem path (development/test)
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
if let cwd = dependencies.currentDirectoryPath(),
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
let data = dependencies.readData(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)
|
||||||
}
|
}
|
||||||
@@ -242,42 +377,20 @@ 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() {
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||||
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 { String($0).trimmingCharacters(in: .whitespaces) }
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "http://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
|
||||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
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 = NotificationCenter.default
|
let center = dependencies.notificationCenter
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
let torReady = center.addObserver(
|
||||||
forName: .TorDidBecomeReady,
|
forName: .TorDidBecomeReady,
|
||||||
@@ -289,38 +402,26 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded(force: true)
|
self.prefetchIfNeeded(force: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
observers.append(torReady)
|
cleanupState.observers.append(torReady)
|
||||||
|
|
||||||
#if os(iOS)
|
if let activeNotificationName = dependencies.activeNotificationName {
|
||||||
let didBecomeActive = center.addObserver(
|
let didBecomeActive = center.addObserver(
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
forName: activeNotificationName,
|
||||||
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() {
|
||||||
refreshTimer?.invalidate()
|
cleanupState.refreshTimer?.invalidate()
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
let interval = dependencies.refreshCheckInterval
|
||||||
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
|
||||||
@@ -329,9 +430,13 @@ final class GeoRelayDirectory {
|
|||||||
self.prefetchIfNeeded()
|
self.prefetchIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
refreshTimer = timer
|
cleanupState.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,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,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?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if teleported {
|
if teleported {
|
||||||
@@ -152,7 +152,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?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
@@ -303,7 +303,7 @@ struct NostrProtocol {
|
|||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + base64URLEncode(combined)
|
return "v2:" + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -314,7 +314,7 @@ struct NostrProtocol {
|
|||||||
// Expect NIP-44 v2 format
|
// Expect NIP-44 v2 format
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = base64URLDecode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
@@ -528,6 +528,26 @@ struct NostrEvent: Codable {
|
|||||||
signed.sig = signatureHex
|
signed.sig = signatureHex
|
||||||
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 = [
|
||||||
@@ -560,24 +580,9 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func base64URLEncode(_ data: Data) -> String {
|
|
||||||
return data.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func base64URLDecode(_ s: String) -> Data? {
|
|
||||||
var str = s
|
|
||||||
let pad = (4 - (str.count % 4)) % 4
|
|
||||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
|
||||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
||||||
return Data(base64Encoded: str)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
|||||||
@@ -4,6 +4,100 @@ 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 {
|
||||||
@@ -27,31 +121,40 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var nextReconnectTime: Date?
|
var nextReconnectTime: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default relay list (can be customized)
|
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub"
|
||||||
"wss://nostr21.com"
|
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
private static let defaultRelaySet = Set(defaultRelays)
|
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||||
|
|
||||||
@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: URLSessionWebSocketTask] = [:]
|
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
||||||
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] = [:]
|
||||||
// Coalesce duplicate subscribe requests for the same id within a short window
|
// Coalesce duplicate subscribe requests for the same id within a short window.
|
||||||
|
private let subscribeCoalesceInterval: TimeInterval = 1.0
|
||||||
private var subscribeCoalesce: [String: Date] = [:]
|
private var subscribeCoalesce: [String: Date] = [:]
|
||||||
|
private var pendingTorConnectionURLs = Set<String>()
|
||||||
|
private var awaitingTorForConnections = false
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private struct SubscriptionRequestState: Equatable {
|
||||||
|
let messageString: String
|
||||||
|
let relayURLs: Set<String>
|
||||||
|
}
|
||||||
|
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||||
|
|
||||||
// Track EOSE per subscription to signal when initial stored events are done
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
var pendingRelays: Set<String>
|
var pendingRelays: Set<String>
|
||||||
@@ -59,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var timer: Timer?
|
var timer: Timer?
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// Pending sends held only for relays that are not yet connected.
|
||||||
@@ -69,8 +173,7 @@ 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 networkService: NetworkActivationService { NetworkActivationService.shared }
|
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
|
||||||
|
|
||||||
// Exponential backoff configuration
|
// Exponential backoff configuration
|
||||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||||
@@ -82,12 +185,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
self.dependencies = .live()
|
||||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||||
|
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
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
dependencies.mutualFavoritesPublisher
|
||||||
.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 }
|
||||||
@@ -95,7 +199,34 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
self.applyDefaultRelayPolicy()
|
self.applyDefaultRelayPolicy()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
LocationChannelManager.shared.$permissionState
|
dependencies.locationPermissionPublisher
|
||||||
|
.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 }
|
||||||
@@ -110,28 +241,8 @@ 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 networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor {
|
connectToRelays(relays.map(\.url), shouldLog: true)
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
|
||||||
Task.detached {
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
await MainActor.run {
|
|
||||||
if !ready {
|
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
|
||||||
for relay in self.relays {
|
|
||||||
self.connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
|
||||||
for relay in self.relays {
|
|
||||||
connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
@@ -144,44 +255,40 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Clear known subscriptions and any queued subs since connections are gone
|
// Clear known subscriptions and any queued subs since connections are gone
|
||||||
subscriptions.removeAll()
|
subscriptions.removeAll()
|
||||||
pendingSubscriptions.removeAll()
|
pendingSubscriptions.removeAll()
|
||||||
|
subscriptionRequestState.removeAll()
|
||||||
|
pendingEOSECallbacks.removeAll()
|
||||||
|
for (_, tracker) in eoseTrackers {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
}
|
||||||
|
eoseTrackers.removeAll()
|
||||||
|
pendingTorConnectionURLs.removeAll()
|
||||||
|
awaitingTorForConnections = false
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 networkService.activationAllowed else { return }
|
guard dependencies.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 && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in targets where !existing.contains(url) {
|
for url in targets where !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
for url in targets where connections[url] == nil {
|
connectToRelays(targets)
|
||||||
connectToRelay(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Defer sends until Tor is ready to avoid premature queueing
|
||||||
Task.detached { [weak self] in
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
let ready = await TorManager.shared.awaitReady()
|
if ready { self.sendEvent(event, to: relayUrls) }
|
||||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempt immediate send to relays with active connections; queue the rest
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
var stillPending = Set<String>()
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connectedConnection(for: relayUrl) {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
} else {
|
} else {
|
||||||
stillPending.insert(relayUrl)
|
stillPending.insert(relayUrl)
|
||||||
@@ -215,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Flush only for a specific relay
|
// Flush only for a specific relay
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
var item = messageQueue[i]
|
||||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
item.pendingRelays.remove(target)
|
item.pendingRelays.remove(target)
|
||||||
if item.pendingRelays.isEmpty {
|
if item.pendingRelays.isEmpty {
|
||||||
@@ -230,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
var item = messageQueue[i]
|
||||||
for url in item.pendingRelays {
|
for url in item.pendingRelays {
|
||||||
if let conn = connections[url] {
|
if let conn = connectedConnection(for: url) {
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
item.pendingRelays.remove(url)
|
item.pendingRelays.remove(url)
|
||||||
}
|
}
|
||||||
@@ -243,6 +350,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||||
|
guard let connection = connections[relayUrl],
|
||||||
|
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
@@ -253,28 +368,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
onEOSE: (() -> Void)? = nil
|
onEOSE: (() -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||||
let now = Date()
|
let now = dependencies.now()
|
||||||
if messageHandlers[id] != nil {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
subscribeCoalesce[id] = now
|
|
||||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
await MainActor.run {
|
|
||||||
if ready {
|
|
||||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
subscribeCoalesce[id] = now
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
@@ -290,14 +390,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||||
let urls = allowedRelayList(from: candidateUrls)
|
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
||||||
|
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subscriptionRequestState[id] = requestState
|
||||||
|
|
||||||
// Always queue subscriptions; sending happens when a relay reports connected
|
// Always queue subscriptions; sending happens when a relay reports connected
|
||||||
let existingSet = Set(relays.map { $0.url })
|
var existingSet = Set(relays.map { $0.url })
|
||||||
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))
|
||||||
|
existingSet.insert(url)
|
||||||
}
|
}
|
||||||
for url in candidateUrls {
|
for url in urls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
var map = self.pendingSubscriptions[url] ?? [:]
|
||||||
map[id] = messageString
|
map[id] = messageString
|
||||||
self.pendingSubscriptions[url] = map
|
self.pendingSubscriptions[url] = map
|
||||||
@@ -306,20 +412,10 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
if let onEOSE = onEOSE {
|
if let onEOSE = onEOSE {
|
||||||
if urls.isEmpty {
|
if urls.isEmpty {
|
||||||
onEOSE()
|
onEOSE()
|
||||||
|
} else if shouldWaitForTorBeforeConnecting {
|
||||||
|
pendingEOSECallbacks[id] = onEOSE
|
||||||
} else {
|
} else {
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
|
||||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if let t = self.eoseTrackers[id] {
|
|
||||||
t.timer?.invalidate()
|
|
||||||
self.eoseTrackers.removeValue(forKey: id)
|
|
||||||
onEOSE()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eoseTrackers[id] = tracker
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
@@ -346,7 +442,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
if networkService.activationAllowed {
|
if dependencies.activationAllowed() {
|
||||||
ensureConnections(to: Self.defaultRelays)
|
ensureConnections(to: Self.defaultRelays)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -356,6 +452,7 @@ 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() {
|
||||||
@@ -376,7 +473,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
for url in urls {
|
for rawURL in urls {
|
||||||
|
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
|
||||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
if seen.insert(url).inserted {
|
if seen.insert(url).inserted {
|
||||||
result.append(url)
|
result.append(url)
|
||||||
@@ -390,6 +488,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||||
subscribeCoalesce.removeValue(forKey: id)
|
subscribeCoalesce.removeValue(forKey: id)
|
||||||
|
subscriptionRequestState.removeValue(forKey: id)
|
||||||
|
pendingEOSECallbacks.removeValue(forKey: id)
|
||||||
|
eoseTrackers[id]?.timer?.invalidate()
|
||||||
|
eoseTrackers.removeValue(forKey: id)
|
||||||
|
for url in Array(pendingSubscriptions.keys) {
|
||||||
|
pendingSubscriptions[url]?.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
let req = NostrRequest.close(id: id)
|
let req = NostrRequest.close(id: id)
|
||||||
let message = try? encoder.encode(req)
|
let message = try? encoder.encode(req)
|
||||||
@@ -400,27 +505,120 @@ 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
|
||||||
Task { @MainActor in
|
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||||
self.subscriptions[relayUrl]?.remove(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||||
|
shouldUseTor && !dependencies.torIsReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
|
||||||
|
guard dependencies.activationAllowed() else { return }
|
||||||
|
let targets = allowedRelayList(from: relayUrls).filter {
|
||||||
|
connections[$0] == nil && !isPermanentlyFailed($0)
|
||||||
|
}
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
|
if shouldWaitForTorBeforeConnecting {
|
||||||
|
queueConnectionsUntilTorReady(targets)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldLog {
|
||||||
|
let route = shouldUseTor ? "via Tor" : "direct"
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in targets {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
|
||||||
|
let targets = allowedRelayList(from: relayUrls).filter {
|
||||||
|
connections[$0] == nil && !isPermanentlyFailed($0)
|
||||||
|
}
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
|
pendingTorConnectionURLs.formUnion(targets)
|
||||||
|
guard !awaitingTorForConnections else { return }
|
||||||
|
|
||||||
|
awaitingTorForConnections = true
|
||||||
|
let generation = connectionGeneration
|
||||||
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
|
guard let self else { return }
|
||||||
|
guard generation == self.connectionGeneration else { return }
|
||||||
|
|
||||||
|
let pending = Array(self.pendingTorConnectionURLs)
|
||||||
|
self.pendingTorConnectionURLs.removeAll()
|
||||||
|
self.awaitingTorForConnections = false
|
||||||
|
|
||||||
|
guard ready else {
|
||||||
|
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.connectToRelays(pending, shouldLog: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
|
||||||
|
guard !requestState.relayURLs.isEmpty else { return true }
|
||||||
|
return requestState.relayURLs.allSatisfy { url in
|
||||||
|
pendingSubscriptions[url]?[id] == requestState.messageString ||
|
||||||
|
subscriptions[url]?.contains(id) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||||
|
eoseTrackers[id]?.timer?.invalidate()
|
||||||
|
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
|
||||||
|
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||||
|
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let self else { return }
|
||||||
|
if let tracker = self.eoseTrackers[id] {
|
||||||
|
tracker.timer?.invalidate()
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eoseTrackers[id] = tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPendingEOSETrackingIfNeeded(id: String) {
|
||||||
|
guard eoseTrackers[id] == nil,
|
||||||
|
let callback = pendingEOSECallbacks.removeValue(forKey: id),
|
||||||
|
let requestState = subscriptionRequestState[id]
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestState.relayURLs.isEmpty {
|
||||||
|
callback()
|
||||||
|
} else {
|
||||||
|
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func connectToRelay(_ urlString: String) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard networkService.activationAllowed else { return }
|
guard dependencies.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 && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,19 +633,12 @@ 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 && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
if shouldWaitForTorBeforeConnecting {
|
||||||
Task.detached { [weak self] in
|
queueConnectionsUntilTorReady([urlString])
|
||||||
guard let self = self else { return }
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
await MainActor.run {
|
|
||||||
if ready { self.connectToRelay(urlString) }
|
|
||||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let session = TorURLSession.shared.session
|
let session = dependencies.makeSession()
|
||||||
let task = session.webSocketTask(with: url)
|
let task = session.webSocketTask(with: url)
|
||||||
|
|
||||||
connections[urlString] = task
|
connections[urlString] = task
|
||||||
@@ -480,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard let connection = connections[relayUrl] else { return }
|
guard let connection = connections[relayUrl] else { return }
|
||||||
for (id, messageString) in map {
|
for (id, messageString) in map {
|
||||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||||
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
connection.send(.string(messageString)) { error in
|
connection.send(.string(messageString)) { error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||||
@@ -495,7 +687,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingSubscriptions[relayUrl] = nil
|
pendingSubscriptions[relayUrl] = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) {
|
private func receiveMessage(from task: NostrRelayConnectionProtocol, 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 }
|
||||||
|
|
||||||
@@ -505,7 +697,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 {
|
||||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
self.handleParsedMessage(parsed, from: relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
if isGiftWrap {
|
if isGiftWrap {
|
||||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .notice:
|
case .notice:
|
||||||
@@ -569,7 +761,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) {
|
private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
let req = NostrRequest.event(event)
|
let req = NostrRequest.event(event)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -601,11 +793,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 = Date()
|
relays[index].lastConnectedAt = dependencies.now()
|
||||||
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 = Date()
|
relays[index].lastDisconnectedAt = dependencies.now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
@@ -621,7 +813,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 !networkService.activationAllowed {
|
if !dependencies.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)
|
||||||
@@ -666,19 +858,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
maxBackoffInterval
|
maxBackoffInterval
|
||||||
)
|
)
|
||||||
|
|
||||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
let nextReconnectTime = dependencies.now().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
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||||
guard let self = self else { return }
|
Task { @MainActor [weak self] in
|
||||||
// Ignore stale scheduled reconnects from a previous generation
|
guard let self = self else { return }
|
||||||
guard gen == self.connectionGeneration else { return }
|
// Ignore stale scheduled reconnects from a previous generation
|
||||||
// Check if we should still reconnect (relay might have been removed)
|
guard gen == self.connectionGeneration else { return }
|
||||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
// Check if we should still reconnect (relay might have been removed)
|
||||||
self.connectToRelay(relayUrl)
|
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||||
|
self.connectToRelay(relayUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -687,20 +881,22 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
/// Manually retry connection to a specific relay
|
||||||
func retryConnection(to relayUrl: String) {
|
func retryConnection(to relayUrl: String) {
|
||||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
|
||||||
|
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
|
||||||
|
|
||||||
// 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[normalizedRelayUrl] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt immediate reconnection
|
// Attempt immediate reconnection
|
||||||
connectToRelay(relayUrl)
|
connectToRelay(normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get detailed status for all relays
|
/// Get detailed status for all relays
|
||||||
@@ -712,6 +908,20 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
nextReconnectTime: relay.nextReconnectTime)
|
nextReconnectTime: relay.nextReconnectTime)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
@@ -764,7 +974,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NostrRelayURL {
|
||||||
|
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||||
|
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if !value.contains("://"), let defaultScheme {
|
||||||
|
value = "\(defaultScheme)://\(value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var components = URLComponents(string: value),
|
||||||
|
let rawScheme = components.scheme?.lowercased(),
|
||||||
|
let rawHost = components.host?.lowercased(),
|
||||||
|
!rawHost.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rawScheme {
|
||||||
|
case "wss", "https":
|
||||||
|
components.scheme = "wss"
|
||||||
|
if components.port == 443 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
case "ws", "http":
|
||||||
|
components.scheme = "ws"
|
||||||
|
if components.port == 80 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
components.host = rawHost
|
||||||
|
if components.path == "/" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
return components.string
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
|
||||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||||
|
|||||||
@@ -60,40 +60,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
// MARK: - Message Types
|
|
||||||
|
|
||||||
/// Simplified BitChat protocol message types.
|
|
||||||
/// Reduced from 24 types to just 6 essential ones.
|
|
||||||
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
|
|
||||||
enum MessageType: UInt8 {
|
|
||||||
// Public messages (unencrypted)
|
|
||||||
case announce = 0x01 // "I'm here" with nickname
|
|
||||||
case message = 0x02 // Public chat message
|
|
||||||
case leave = 0x03 // "I'm leaving"
|
|
||||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
|
||||||
|
|
||||||
// Noise encryption
|
|
||||||
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
|
|
||||||
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
|
|
||||||
|
|
||||||
// Fragmentation (simplified)
|
|
||||||
case fragment = 0x20 // Single fragment type for large messages
|
|
||||||
case fileTransfer = 0x22 // Binary file/audio/image payloads
|
|
||||||
|
|
||||||
var description: String {
|
|
||||||
switch self {
|
|
||||||
case .announce: return "announce"
|
|
||||||
case .message: return "message"
|
|
||||||
case .leave: return "leave"
|
|
||||||
case .requestSync: return "requestSync"
|
|
||||||
case .noiseHandshake: return "noiseHandshake"
|
|
||||||
case .noiseEncrypted: return "noiseEncrypted"
|
|
||||||
case .fragment: return "fragment"
|
|
||||||
case .fileTransfer: return "fileTransfer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Payload Types
|
// MARK: - Noise Payload Types
|
||||||
|
|
||||||
@@ -131,35 +98,6 @@ enum LazyHandshakeState {
|
|||||||
case failed(Error) // Handshake failed
|
case failed(Error) // Handshake failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delivery Status
|
|
||||||
|
|
||||||
// Delivery status for messages
|
|
||||||
enum DeliveryStatus: Codable, Equatable, Hashable {
|
|
||||||
case sending
|
|
||||||
case sent // Left our device
|
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
|
||||||
case read(by: String, at: Date) // Seen by recipient
|
|
||||||
case failed(reason: String)
|
|
||||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
|
||||||
|
|
||||||
var displayText: String {
|
|
||||||
switch self {
|
|
||||||
case .sending:
|
|
||||||
return "Sending..."
|
|
||||||
case .sent:
|
|
||||||
return "Sent"
|
|
||||||
case .delivered(let nickname, _):
|
|
||||||
return "Delivered to \(nickname)"
|
|
||||||
case .read(let nickname, _):
|
|
||||||
return "Read by \(nickname)"
|
|
||||||
case .failed(let reason):
|
|
||||||
return "Failed: \(reason)"
|
|
||||||
case .partiallyDelivered(let reached, let total):
|
|
||||||
return "Delivered to \(reached)/\(total)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEAnnouncePreflightAcceptance {
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
let derivedPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightRejection: Equatable {
|
||||||
|
case malformed
|
||||||
|
case senderMismatch(derivedPeerID: PeerID)
|
||||||
|
case selfAnnounce
|
||||||
|
case stale(ageSeconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightDecision {
|
||||||
|
case accept(BLEAnnouncePreflightAcceptance)
|
||||||
|
case reject(BLEAnnouncePreflightRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
now: Date
|
||||||
|
) -> BLEAnnouncePreflightDecision {
|
||||||
|
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||||
|
return .reject(.malformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||||
|
guard derivedPeerID == peerID else {
|
||||||
|
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard peerID != localPeerID else {
|
||||||
|
return .reject(.selfAnnounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
||||||
|
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .accept(BLEAnnouncePreflightAcceptance(
|
||||||
|
announcement: announcement,
|
||||||
|
derivedPeerID: derivedPeerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustRejection: Equatable {
|
||||||
|
case missingSignature
|
||||||
|
case invalidSignature
|
||||||
|
case keyMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
|
case verified
|
||||||
|
case reject(BLEAnnounceTrustRejection)
|
||||||
|
|
||||||
|
var isVerified: Bool {
|
||||||
|
self == .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
hasSignature: Bool,
|
||||||
|
signatureValid: Bool,
|
||||||
|
existingNoisePublicKey: Data?,
|
||||||
|
announcedNoisePublicKey: Data
|
||||||
|
) -> BLEAnnounceTrustDecision {
|
||||||
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
|
return .reject(.keyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasSignature else {
|
||||||
|
return .reject(.missingSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard signatureValid else {
|
||||||
|
return .reject(.invalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEAnnounceResponsePlan: Equatable {
|
||||||
|
let shouldNotifyPeerConnected: Bool
|
||||||
|
let shouldScheduleInitialSync: Bool
|
||||||
|
let shouldSendAnnounceBack: Bool
|
||||||
|
let shouldScheduleAfterglow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceResponsePolicy {
|
||||||
|
static func plan(
|
||||||
|
isDirectAnnounce: Bool,
|
||||||
|
isNewPeer: Bool,
|
||||||
|
isReconnectedPeer: Bool,
|
||||||
|
shouldSendAnnounceBack: Bool
|
||||||
|
) -> BLEAnnounceResponsePlan {
|
||||||
|
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
||||||
|
|
||||||
|
return BLEAnnounceResponsePlan(
|
||||||
|
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
||||||
|
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
||||||
|
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
||||||
|
shouldScheduleAfterglow: isNewPeer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEAnnounceThrottle {
|
||||||
|
private var lastSent: Date
|
||||||
|
private let normalMinimumInterval: TimeInterval
|
||||||
|
private let forcedMinimumInterval: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
lastSent: Date = .distantPast,
|
||||||
|
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
|
||||||
|
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
|
||||||
|
) {
|
||||||
|
self.lastSent = lastSent
|
||||||
|
self.normalMinimumInterval = normalMinimumInterval
|
||||||
|
self.forcedMinimumInterval = forcedMinimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func elapsed(since now: Date) -> TimeInterval {
|
||||||
|
now.timeIntervalSince(lastSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||||
|
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||||
|
guard elapsed(since: now) >= minimumInterval else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSent = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEConnectionCandidate<Peripheral> {
|
||||||
|
let peripheral: Peripheral
|
||||||
|
let peripheralID: String
|
||||||
|
let rssi: Int
|
||||||
|
let name: String
|
||||||
|
let isConnectable: Bool
|
||||||
|
let discoveredAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEExistingConnectionState {
|
||||||
|
let isConnecting: Bool
|
||||||
|
let isConnected: Bool
|
||||||
|
let lastConnectionAttempt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPeripheralConnectionState {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEDiscoveryDecision: Equatable {
|
||||||
|
case ignore
|
||||||
|
case queued
|
||||||
|
case scheduleRetry(after: TimeInterval)
|
||||||
|
case cancelStaleConnection
|
||||||
|
case connectNow
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEConnectionQueueDecision<Peripheral> {
|
||||||
|
case none
|
||||||
|
case retryAfter(TimeInterval)
|
||||||
|
case connect(BLEConnectionCandidate<Peripheral>)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class BLEConnectionScheduler<Peripheral> {
|
||||||
|
private let maxCentralLinks: Int
|
||||||
|
private let connectRateLimitInterval: TimeInterval
|
||||||
|
private let candidateCap: Int
|
||||||
|
private let weakLinkCooldownSeconds: TimeInterval
|
||||||
|
private let weakLinkRSSICutoff: Int
|
||||||
|
private let recentTimeoutWindowSeconds: TimeInterval
|
||||||
|
private let recentTimeoutCountThreshold: Int
|
||||||
|
|
||||||
|
private var lastGlobalConnectAttempt: Date = .distantPast
|
||||||
|
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
|
||||||
|
private var failureCounts: [String: Int] = [:]
|
||||||
|
private var recentConnectTimeouts: [String: Date] = [:]
|
||||||
|
private var lastIsolatedAt: Date?
|
||||||
|
|
||||||
|
private let initialDynamicRSSIThreshold: Int
|
||||||
|
private(set) var dynamicRSSIThreshold: Int
|
||||||
|
|
||||||
|
var candidateCount: Int {
|
||||||
|
candidates.count
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
|
||||||
|
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
|
||||||
|
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
||||||
|
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
||||||
|
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
||||||
|
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
|
||||||
|
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
|
||||||
|
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||||
|
) {
|
||||||
|
self.maxCentralLinks = maxCentralLinks
|
||||||
|
self.connectRateLimitInterval = connectRateLimitInterval
|
||||||
|
self.candidateCap = candidateCap
|
||||||
|
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
||||||
|
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
||||||
|
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
|
||||||
|
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
|
||||||
|
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
||||||
|
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDiscovery(
|
||||||
|
_ candidate: BLEConnectionCandidate<Peripheral>,
|
||||||
|
connectedOrConnectingCount: Int,
|
||||||
|
existingState: BLEExistingConnectionState?,
|
||||||
|
peripheralState: BLEPeripheralConnectionState,
|
||||||
|
now: Date
|
||||||
|
) -> BLEDiscoveryDecision {
|
||||||
|
guard candidate.isConnectable else { return .ignore }
|
||||||
|
|
||||||
|
if candidate.rssi <= dynamicRSSIThreshold {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .queued
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectedOrConnectingCount >= maxCentralLinks {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .queued
|
||||||
|
}
|
||||||
|
|
||||||
|
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .scheduleRetry(after: retryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existingState {
|
||||||
|
if existingState.isConnected || existingState.isConnecting {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastAttempt = existingState.lastConnectionAttempt,
|
||||||
|
now.timeIntervalSince(lastAttempt) < 2.0 {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
||||||
|
now.timeIntervalSince(lastTimeout) < 15 {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
switch peripheralState {
|
||||||
|
case .disconnected:
|
||||||
|
return .connectNow
|
||||||
|
case .connecting, .connected:
|
||||||
|
return .cancelStaleConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
|
||||||
|
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
|
||||||
|
candidates[existingIndex] = candidate
|
||||||
|
} else {
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort {
|
||||||
|
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
|
||||||
|
return $0.discoveredAt < $1.discoveredAt
|
||||||
|
}
|
||||||
|
if candidates.count > candidateCap {
|
||||||
|
candidates.removeLast(candidates.count - candidateCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextCandidate(
|
||||||
|
connectedOrConnectingCount: Int,
|
||||||
|
isAlreadyConnectingOrConnected: (String) -> Bool,
|
||||||
|
now: Date
|
||||||
|
) -> BLEConnectionQueueDecision<Peripheral> {
|
||||||
|
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
|
||||||
|
|
||||||
|
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||||
|
return .retryAfter(retryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
while !candidates.isEmpty {
|
||||||
|
candidates.sort { score($0, now: now) > score($1, now: now) }
|
||||||
|
let candidate = candidates.removeFirst()
|
||||||
|
guard candidate.isConnectable else { continue }
|
||||||
|
|
||||||
|
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .retryAfter(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return .connect(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionAttempt(at now: Date) {
|
||||||
|
lastGlobalConnectAttempt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionSuccess(peripheralID: String) {
|
||||||
|
failureCounts[peripheralID] = 0
|
||||||
|
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionFailure(peripheralID: String) {
|
||||||
|
failureCounts[peripheralID, default: 0] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDisconnectError(peripheralID: String, at now: Date) {
|
||||||
|
recentConnectTimeouts[peripheralID] = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
||||||
|
recentConnectTimeouts[peripheralID] = now
|
||||||
|
recordConnectionFailure(peripheralID: peripheralID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||||
|
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
lastGlobalConnectAttempt = .distantPast
|
||||||
|
candidates.removeAll()
|
||||||
|
failureCounts.removeAll()
|
||||||
|
recentConnectTimeouts.removeAll()
|
||||||
|
lastIsolatedAt = nil
|
||||||
|
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateRSSIThreshold(
|
||||||
|
connectedCount: Int,
|
||||||
|
connectedOrConnectingLinkCount: Int,
|
||||||
|
now: Date
|
||||||
|
) -> Int {
|
||||||
|
if connectedCount == 0 {
|
||||||
|
if lastIsolatedAt == nil { lastIsolatedAt = now }
|
||||||
|
let isolatedAt = lastIsolatedAt ?? now
|
||||||
|
let elapsed = now.timeIntervalSince(isolatedAt)
|
||||||
|
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
|
||||||
|
? TransportConfig.bleRSSIIsolatedRelaxed
|
||||||
|
: TransportConfig.bleRSSIIsolatedBase
|
||||||
|
return dynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIsolatedAt = nil
|
||||||
|
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||||
|
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
|
||||||
|
threshold = TransportConfig.bleRSSIConnectedThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
let recentTimeouts = recentConnectTimeouts.filter {
|
||||||
|
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
|
||||||
|
}.count
|
||||||
|
if recentTimeouts >= recentTimeoutCountThreshold {
|
||||||
|
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicRSSIThreshold = threshold
|
||||||
|
return threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
|
||||||
|
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
|
||||||
|
guard elapsed < connectRateLimitInterval else { return nil }
|
||||||
|
return connectRateLimitInterval - elapsed + 0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weakLinkRetryDelay(
|
||||||
|
for candidate: BLEConnectionCandidate<Peripheral>,
|
||||||
|
now: Date
|
||||||
|
) -> TimeInterval? {
|
||||||
|
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
|
||||||
|
let elapsed = now.timeIntervalSince(lastTimeout)
|
||||||
|
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
|
||||||
|
let remaining = weakLinkCooldownSeconds - elapsed
|
||||||
|
return min(max(2.0, remaining), 15.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
|
||||||
|
let failures = failureCounts[candidate.peripheralID] ?? 0
|
||||||
|
let penalty = min(20, 1 << min(4, failures))
|
||||||
|
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
|
||||||
|
now.timeIntervalSince($0) < 60 ? 10 : 0
|
||||||
|
} ?? 0
|
||||||
|
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
|
||||||
|
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
|
||||||
|
return base + recency - penalty - timeoutBias
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEDirectedRelaySpoolEntry {
|
||||||
|
let recipient: PeerID
|
||||||
|
let packet: BitchatPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEDirectedRelaySpool {
|
||||||
|
private struct StoredPacket {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let enqueuedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
packetsByRecipient.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
packetsByRecipient.values.reduce(0) { $0 + $1.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func enqueue(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
recipient: PeerID,
|
||||||
|
messageID: String,
|
||||||
|
enqueuedAt: Date
|
||||||
|
) -> Bool {
|
||||||
|
var packets = packetsByRecipient[recipient] ?? [:]
|
||||||
|
guard packets[messageID] == nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
|
||||||
|
packetsByRecipient[recipient] = packets
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
|
||||||
|
var entries: [BLEDirectedRelaySpoolEntry] = []
|
||||||
|
|
||||||
|
for (recipient, packets) in packetsByRecipient {
|
||||||
|
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
|
||||||
|
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packetsByRecipient.removeAll()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func pruneExpired(now: Date, window: TimeInterval) {
|
||||||
|
guard !packetsByRecipient.isEmpty else { return }
|
||||||
|
|
||||||
|
var pruned: [PeerID: [String: StoredPacket]] = [:]
|
||||||
|
for (recipient, packets) in packetsByRecipient {
|
||||||
|
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
|
||||||
|
if !freshPackets.isEmpty {
|
||||||
|
pruned[recipient] = freshPackets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packetsByRecipient = pruned
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
packetsByRecipient.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFanoutSelection: Equatable {
|
||||||
|
let peripheralIDs: Set<String>
|
||||||
|
let centralIDs: Set<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEFanoutSelector {
|
||||||
|
static func selectLinks(
|
||||||
|
peripheralIDs: [String],
|
||||||
|
centralIDs: [String],
|
||||||
|
ingressLink: BLEIngressLinkID?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||||
|
directedPeerHint: PeerID?,
|
||||||
|
packetType: UInt8,
|
||||||
|
messageID: String
|
||||||
|
) -> BLEFanoutSelection {
|
||||||
|
let allowed = allowedLinks(
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
ingressLink: ingressLink,
|
||||||
|
excludedLinks: excludedLinks
|
||||||
|
)
|
||||||
|
|
||||||
|
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||||
|
return BLEFanoutSelection(
|
||||||
|
peripheralIDs: Set(allowed.peripheralIDs),
|
||||||
|
centralIDs: Set(allowed.centralIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEFanoutSelection(
|
||||||
|
peripheralIDs: deterministicSubset(
|
||||||
|
ids: allowed.peripheralIDs,
|
||||||
|
k: subsetSize(for: allowed.peripheralIDs.count),
|
||||||
|
seed: messageID
|
||||||
|
),
|
||||||
|
centralIDs: deterministicSubset(
|
||||||
|
ids: allowed.centralIDs,
|
||||||
|
k: subsetSize(for: allowed.centralIDs.count),
|
||||||
|
seed: messageID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func allowedLinks(
|
||||||
|
peripheralIDs: [String],
|
||||||
|
centralIDs: [String],
|
||||||
|
ingressLink: BLEIngressLinkID?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID>
|
||||||
|
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||||
|
var allowedPeripheralIDs = peripheralIDs
|
||||||
|
var allowedCentralIDs = centralIDs
|
||||||
|
var blockedLinks = excludedLinks
|
||||||
|
|
||||||
|
if let ingressLink {
|
||||||
|
blockedLinks.insert(ingressLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
||||||
|
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
||||||
|
|
||||||
|
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
||||||
|
directedPeerHint == nil
|
||||||
|
&& packetType != MessageType.fragment.rawValue
|
||||||
|
&& packetType != MessageType.announce.rawValue
|
||||||
|
&& packetType != MessageType.requestSync.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func subsetSize(for count: Int) -> Int {
|
||||||
|
guard count > 0 else { return 0 }
|
||||||
|
if count <= 2 { return count }
|
||||||
|
|
||||||
|
var value = count - 1
|
||||||
|
var bits = 0
|
||||||
|
while value > 0 {
|
||||||
|
value >>= 1
|
||||||
|
bits += 1
|
||||||
|
}
|
||||||
|
return min(count, max(1, bits + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||||
|
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||||
|
|
||||||
|
var scored: [(score: [UInt8], id: String)] = []
|
||||||
|
for id in ids {
|
||||||
|
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||||
|
let digest = Array(SHA256.hash(data: data))
|
||||||
|
scored.append((digest, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort { lhs, rhs in
|
||||||
|
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
||||||
|
if lhs.score[index] != rhs.score[index] {
|
||||||
|
return lhs.score[index] < rhs.score[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set(scored.prefix(k).map(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFileTransferDeliveryPlan: Equatable {
|
||||||
|
let isPrivateMessage: Bool
|
||||||
|
let shouldTrackForSync: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEFileTransferPolicy {
|
||||||
|
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
|
||||||
|
peerID == localPeerID && packet.ttl != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
|
||||||
|
guard let recipientID = packet.recipientID else {
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
|
||||||
|
if isBroadcast {
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard PeerID(hexData: recipientID) == localPeerID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIncomingFileAcceptance {
|
||||||
|
let filePacket: BitchatFilePacket
|
||||||
|
let mime: MimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIncomingFileRejection: Error, Equatable {
|
||||||
|
case malformedPayload
|
||||||
|
case payloadTooLarge(bytes: Int)
|
||||||
|
case unsupportedMime(mimeType: String?, bytes: Int)
|
||||||
|
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIncomingFileValidator {
|
||||||
|
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||||
|
return .failure(.malformedPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||||
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
||||||
|
return .failure(.unsupportedMime(
|
||||||
|
mimeType: filePacket.mimeType,
|
||||||
|
bytes: filePacket.content.count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard mime.matches(data: filePacket.content) else {
|
||||||
|
return .failure(.magicMismatch(
|
||||||
|
mime: mime,
|
||||||
|
bytes: filePacket.content.count,
|
||||||
|
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFragmentKey: Hashable, Equatable {
|
||||||
|
let sender: UInt64
|
||||||
|
let id: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEFragmentHeader: Equatable {
|
||||||
|
let key: BLEFragmentKey
|
||||||
|
let index: Int
|
||||||
|
let total: Int
|
||||||
|
let originalType: UInt8
|
||||||
|
let fragmentData: Data
|
||||||
|
let isBroadcastFragment: Bool
|
||||||
|
|
||||||
|
var idLogString: String {
|
||||||
|
String(format: "%016llx", key.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(packet: BitchatPacket) {
|
||||||
|
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
|
||||||
|
guard packet.payload.count >= 13 else { return nil }
|
||||||
|
|
||||||
|
var senderU64: UInt64 = 0
|
||||||
|
for byte in packet.senderID.prefix(8) {
|
||||||
|
senderU64 = (senderU64 << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragmentU64: UInt64 = 0
|
||||||
|
for byte in packet.payload.prefix(8) {
|
||||||
|
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
|
||||||
|
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
|
||||||
|
|
||||||
|
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcastFragment: Bool = {
|
||||||
|
guard let recipient = packet.recipientID else { return true }
|
||||||
|
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
|
||||||
|
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
|
||||||
|
self.index = index
|
||||||
|
self.total = total
|
||||||
|
self.originalType = packet.payload[12]
|
||||||
|
self.fragmentData = Data(packet.payload.suffix(from: 13))
|
||||||
|
self.isBroadcastFragment = isBroadcastFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEFragmentAssemblyBuffer {
|
||||||
|
enum AppendResult: Equatable {
|
||||||
|
case stored(header: BLEFragmentHeader, started: Bool)
|
||||||
|
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
|
||||||
|
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metadata {
|
||||||
|
let type: UInt8
|
||||||
|
let total: Int
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||||
|
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
fragmentsByKey.removeAll()
|
||||||
|
metadataByKey.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func removeExpired(before cutoff: Date) -> Int {
|
||||||
|
let expiredKeys = metadataByKey
|
||||||
|
.filter { $0.value.timestamp < cutoff }
|
||||||
|
.map(\.key)
|
||||||
|
|
||||||
|
for key in expiredKeys {
|
||||||
|
fragmentsByKey.removeValue(forKey: key)
|
||||||
|
metadataByKey.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiredKeys.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(
|
||||||
|
_ header: BLEFragmentHeader,
|
||||||
|
maxInFlightAssemblies: Int,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> AppendResult {
|
||||||
|
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
|
||||||
|
|
||||||
|
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||||
|
let limit = Self.assemblyLimit(for: header.originalType)
|
||||||
|
let projectedSize = currentSize + header.fragmentData.count
|
||||||
|
|
||||||
|
guard projectedSize <= limit else {
|
||||||
|
fragmentsByKey.removeValue(forKey: header.key)
|
||||||
|
metadataByKey.removeValue(forKey: header.key)
|
||||||
|
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||||
|
|
||||||
|
guard let fragments = fragmentsByKey[header.key],
|
||||||
|
fragments.count == header.total else {
|
||||||
|
return .stored(header: header, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
|
||||||
|
if let fragment = fragments[index] {
|
||||||
|
data.append(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey.removeValue(forKey: header.key)
|
||||||
|
metadataByKey.removeValue(forKey: header.key)
|
||||||
|
|
||||||
|
return .complete(header: header, reassembledData: reassembled, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func startAssemblyIfNeeded(
|
||||||
|
for header: BLEFragmentHeader,
|
||||||
|
maxInFlightAssemblies: Int,
|
||||||
|
now: Date
|
||||||
|
) -> Bool {
|
||||||
|
guard fragmentsByKey[header.key] == nil else { return false }
|
||||||
|
|
||||||
|
if fragmentsByKey.count >= maxInFlightAssemblies,
|
||||||
|
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||||
|
fragmentsByKey.removeValue(forKey: oldest)
|
||||||
|
metadataByKey.removeValue(forKey: oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey[header.key] = [:]
|
||||||
|
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileTransferLimits.maxPayloadBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEInboundWriteChunk: Equatable {
|
||||||
|
let offset: Int
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEInboundWriteAppendMetadata: Equatable {
|
||||||
|
let accumulatedBytes: Int
|
||||||
|
let appendedBytes: Int
|
||||||
|
let offsets: [Int]
|
||||||
|
let packetType: UInt8?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEInboundWriteBuffer {
|
||||||
|
enum AppendResult {
|
||||||
|
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
case waiting(metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
case oversized(metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buffersByCentralID: [String: Data] = [:]
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
buffersByCentralID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(
|
||||||
|
chunks: [BLEInboundWriteChunk],
|
||||||
|
for centralID: String,
|
||||||
|
capBytes: Int
|
||||||
|
) -> AppendResult {
|
||||||
|
var combined = buffersByCentralID[centralID] ?? Data()
|
||||||
|
var appendedBytes = 0
|
||||||
|
var offsets: [Int] = []
|
||||||
|
|
||||||
|
for chunk in chunks where !chunk.data.isEmpty {
|
||||||
|
offsets.append(chunk.offset)
|
||||||
|
let end = chunk.offset + chunk.data.count
|
||||||
|
|
||||||
|
if combined.count < end {
|
||||||
|
combined.append(Data(repeating: 0, count: end - combined.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
|
||||||
|
appendedBytes += chunk.data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = BLEInboundWriteAppendMetadata(
|
||||||
|
accumulatedBytes: combined.count,
|
||||||
|
appendedBytes: appendedBytes,
|
||||||
|
offsets: offsets,
|
||||||
|
packetType: combined.count >= 2 ? combined[1] : nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if let packet = BinaryProtocol.decode(combined) {
|
||||||
|
buffersByCentralID.removeValue(forKey: centralID)
|
||||||
|
return .decoded(packet: packet, metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard combined.count <= capBytes else {
|
||||||
|
buffersByCentralID.removeValue(forKey: centralID)
|
||||||
|
return .oversized(metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffersByCentralID[centralID] = combined
|
||||||
|
return .waiting(metadata: metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEIncomingFileStore {
|
||||||
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let baseDirectory: URL?
|
||||||
|
private let dateProvider: () -> Date
|
||||||
|
|
||||||
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.baseDirectory = baseDirectory
|
||||||
|
self.dateProvider = dateProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(
|
||||||
|
data: Data,
|
||||||
|
preferredName: String?,
|
||||||
|
subdirectory: String,
|
||||||
|
fallbackExtension: String?,
|
||||||
|
defaultPrefix: String
|
||||||
|
) -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
let sanitized = sanitizedFileName(
|
||||||
|
preferredName,
|
||||||
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
|
fallbackExtension: fallbackExtension
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||||
|
try data.write(to: destination, options: .atomic)
|
||||||
|
return destination
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory()
|
||||||
|
let incomingDirs = [
|
||||||
|
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||||
|
]
|
||||||
|
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||||
|
|
||||||
|
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||||
|
guard let contents = try? fileManager.contentsOfDirectory(
|
||||||
|
at: dir,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { continue }
|
||||||
|
|
||||||
|
for fileURL in contents {
|
||||||
|
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
||||||
|
let size = attrs.fileSize,
|
||||||
|
let modified = attrs.contentModificationDate else { continue }
|
||||||
|
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
|
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||||
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
|
let needToFree = currentUsage - targetUsage
|
||||||
|
var freedSpace: Int64 = 0
|
||||||
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
|
guard freedSpace < needToFree else { break }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: file.url)
|
||||||
|
freedSpace += file.size
|
||||||
|
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if freedSpace > 0 {
|
||||||
|
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filesDirectory() throws -> URL {
|
||||||
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
var candidate = (name ?? "")
|
||||||
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
.precomposedStringWithCanonicalMapping
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "\\", with: "_")
|
||||||
|
|
||||||
|
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||||
|
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
||||||
|
if candidate.isEmpty { candidate = defaultName }
|
||||||
|
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
||||||
|
|
||||||
|
if candidate.count > 120 {
|
||||||
|
let ext = (candidate as NSString).pathExtension
|
||||||
|
let base = (candidate as NSString).deletingPathExtension
|
||||||
|
candidate = ext.isEmpty
|
||||||
|
? String(candidate.prefix(120))
|
||||||
|
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
||||||
|
candidate += ".\(fallbackExtension)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||||
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
for counter in 1..<100 {
|
||||||
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
|
candidate = directory.appendingPathComponent(newName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func timestampString(from date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEIngressLinkID: Hashable, Equatable {
|
||||||
|
case peripheral(String)
|
||||||
|
case central(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressPacketContext: Equatable {
|
||||||
|
let receivedFromPeerID: PeerID
|
||||||
|
let validationPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressLinkRecord: Equatable {
|
||||||
|
let link: BLEIngressLinkID
|
||||||
|
let peerID: PeerID
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIngressRejection: Error, Equatable {
|
||||||
|
case selfLoopback(packetType: UInt8)
|
||||||
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressLinkRegistry {
|
||||||
|
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
ingressByMessageID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
ingressByMessageID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
||||||
|
ingressByMessageID[Self.messageID(for: packet)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
||||||
|
record(for: packet)?.link
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(for packet: BitchatPacket) -> PeerID? {
|
||||||
|
record(for: packet)?.peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func recordIfNew(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
link: BLEIngressLinkID,
|
||||||
|
peerID: PeerID,
|
||||||
|
now: Date = Date(),
|
||||||
|
lifetime: TimeInterval
|
||||||
|
) -> Bool {
|
||||||
|
let messageID = Self.messageID(for: packet)
|
||||||
|
if let existing = ingressByMessageID[messageID],
|
||||||
|
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prune(before cutoff: Date) {
|
||||||
|
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func packetContext(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
claimedSenderID: PeerID,
|
||||||
|
boundPeerID: PeerID?,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
directAnnounceTTL: UInt8
|
||||||
|
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
||||||
|
if claimedSenderID == localPeerID,
|
||||||
|
!isSelfAuthoredSyncResponse(packet) {
|
||||||
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let boundPeerID,
|
||||||
|
boundPeerID != claimedSenderID,
|
||||||
|
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||||
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||||
|
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
||||||
|
return .success(BLEIngressPacketContext(
|
||||||
|
receivedFromPeerID: receivedFromPeerID,
|
||||||
|
validationPeerID: validationPeerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func messageID(for packet: BitchatPacket) -> String {
|
||||||
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||||
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||||
|
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||||
|
packet.isRSR && packet.ttl == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEIngressPacketGuard {
|
||||||
|
enum Rejection: Error, Equatable {
|
||||||
|
case selfLoopback(packetType: UInt8)
|
||||||
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||||
|
case invalidRSR(peerID: PeerID)
|
||||||
|
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
claimedSenderID: PeerID,
|
||||||
|
boundPeerID: PeerID?,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
directAnnounceTTL: UInt8,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<BLEIngressPacketContext, Rejection> {
|
||||||
|
let contextResult = BLEIngressLinkRegistry.packetContext(
|
||||||
|
for: packet,
|
||||||
|
claimedSenderID: claimedSenderID,
|
||||||
|
boundPeerID: boundPeerID,
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
directAnnounceTTL: directAnnounceTTL
|
||||||
|
)
|
||||||
|
|
||||||
|
let context: BLEIngressPacketContext
|
||||||
|
switch contextResult {
|
||||||
|
case .success(let acceptedContext):
|
||||||
|
context = acceptedContext
|
||||||
|
case .failure(.selfLoopback(let packetType)):
|
||||||
|
return .failure(.selfLoopback(packetType: packetType))
|
||||||
|
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||||||
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch validatePayload(
|
||||||
|
packet,
|
||||||
|
from: context.validationPeerID,
|
||||||
|
nowMs: nowMs,
|
||||||
|
maxTimestampSkewMs: maxTimestampSkewMs,
|
||||||
|
isValidSyncResponse: isValidSyncResponse
|
||||||
|
) {
|
||||||
|
case .success:
|
||||||
|
return .success(context)
|
||||||
|
case .failure(let rejection):
|
||||||
|
return .failure(rejection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validatePayload(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<Void, Rejection> {
|
||||||
|
if packet.isRSR {
|
||||||
|
guard isValidSyncResponse(peerID) else {
|
||||||
|
return .failure(.invalidRSR(peerID: peerID))
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let packetTime = packet.timestamp
|
||||||
|
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
|
||||||
|
guard skew <= maxTimestampSkewMs else {
|
||||||
|
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeripheralLinkState {
|
||||||
|
let peripheral: CBPeripheral
|
||||||
|
var characteristic: CBCharacteristic?
|
||||||
|
var peerID: PeerID?
|
||||||
|
var isConnecting: Bool
|
||||||
|
var isConnected: Bool
|
||||||
|
var lastConnectionAttempt: Date?
|
||||||
|
var assembler: NotificationStreamAssembler
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEDirectLinkState: Equatable {
|
||||||
|
let hasPeripheral: Bool
|
||||||
|
let hasCentral: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLESubscribedCentralSnapshot {
|
||||||
|
let centrals: [CBCentral]
|
||||||
|
let peerIDsByCentralUUID: [String: PeerID]
|
||||||
|
|
||||||
|
func central(for peerID: PeerID) -> CBCentral? {
|
||||||
|
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class BLELinkStateStore {
|
||||||
|
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
|
||||||
|
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
|
||||||
|
private(set) var subscribedCentrals: [CBCentral] = []
|
||||||
|
private(set) var centralToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
|
var peripheralStates: [BLEPeripheralLinkState] {
|
||||||
|
Array(peripherals.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||||
|
BLESubscribedCentralSnapshot(
|
||||||
|
centrals: subscribedCentrals,
|
||||||
|
peerIDsByCentralUUID: centralToPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribedCentralCount: Int {
|
||||||
|
subscribedCentrals.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedOrConnectingPeripheralCount: Int {
|
||||||
|
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
||||||
|
peripherals[peripheralID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
||||||
|
peripherals[peripheralID] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updatePeripheral(
|
||||||
|
_ peripheralID: String,
|
||||||
|
_ update: (inout BLEPeripheralLinkState) -> Void
|
||||||
|
) -> BLEPeripheralLinkState? {
|
||||||
|
guard var state = peripherals[peripheralID] else { return nil }
|
||||||
|
update(&state)
|
||||||
|
peripherals[peripheralID] = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
|
||||||
|
setPeripheralState(
|
||||||
|
BLEPeripheralLinkState(
|
||||||
|
peripheral: peripheral,
|
||||||
|
characteristic: nil,
|
||||||
|
peerID: nil,
|
||||||
|
isConnecting: true,
|
||||||
|
isConnected: false,
|
||||||
|
lastConnectionAttempt: date,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
|
),
|
||||||
|
for: peripheral.identifier.uuidString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markConnected(_ peripheral: CBPeripheral) {
|
||||||
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
if updatePeripheral(peripheralID, {
|
||||||
|
$0.isConnecting = false
|
||||||
|
$0.isConnected = true
|
||||||
|
}) == nil {
|
||||||
|
setPeripheralState(
|
||||||
|
BLEPeripheralLinkState(
|
||||||
|
peripheral: peripheral,
|
||||||
|
characteristic: nil,
|
||||||
|
peerID: nil,
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
lastConnectionAttempt: nil,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
|
),
|
||||||
|
for: peripheralID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
|
||||||
|
updatePeripheral(peripheralID) {
|
||||||
|
$0.characteristic = characteristic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||||
|
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
|
||||||
|
let peripheralUUID = peerToPeripheralUUID[peerID]
|
||||||
|
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
|
||||||
|
let hasCentral = centralToPeerID.values.contains(peerID)
|
||||||
|
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
||||||
|
}
|
||||||
|
|
||||||
|
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||||
|
guard let peerID else { return [] }
|
||||||
|
|
||||||
|
var links: Set<BLEIngressLinkID> = []
|
||||||
|
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
||||||
|
links.insert(.peripheral(peripheralUUID))
|
||||||
|
}
|
||||||
|
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||||
|
links.insert(.central(centralUUID))
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||||
|
peripherals[peripheralID]?.peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||||
|
centralToPeerID[centralUUID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSubscribedCentral(_ central: CBCentral) {
|
||||||
|
guard !subscribedCentrals.contains(central) else { return }
|
||||||
|
subscribedCentrals.append(central)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
|
||||||
|
let centralUUID = central.identifier.uuidString
|
||||||
|
subscribedCentrals.removeAll { $0.identifier == central.identifier }
|
||||||
|
return centralToPeerID.removeValue(forKey: centralUUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||||
|
centralToPeerID[centralUUID] = peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||||
|
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||||
|
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||||
|
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
|
||||||
|
if let peerID {
|
||||||
|
peerToPeripheralUUID.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
return peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPeripherals() -> [PeerID] {
|
||||||
|
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
return peerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCentrals() -> [PeerID] {
|
||||||
|
let peerIDs = Array(centralToPeerID.values)
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
|
return peerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class BLELogRateLimiter {
|
||||||
|
private let defaultMinimumInterval: TimeInterval
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
|
||||||
|
private var lastLogTimeByKey: [String: Date] = [:]
|
||||||
|
|
||||||
|
init(defaultMinimumInterval: TimeInterval) {
|
||||||
|
self.defaultMinimumInterval = defaultMinimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLog(
|
||||||
|
key: String,
|
||||||
|
now: Date = Date(),
|
||||||
|
minimumInterval: TimeInterval? = nil
|
||||||
|
) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
let interval = minimumInterval ?? defaultMinimumInterval
|
||||||
|
if let lastLogTime = lastLogTimeByKey[key],
|
||||||
|
now.timeIntervalSince(lastLogTime) < interval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lastLogTimeByKey[key] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
queue.sync {
|
||||||
|
lastLogTimeByKey.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEMaintenancePlan: Equatable {
|
||||||
|
let shouldSendAnnounce: Bool
|
||||||
|
let shouldEnsureAdvertising: Bool
|
||||||
|
let shouldRunCleanup: Bool
|
||||||
|
let shouldFlushDirectedSpool: Bool
|
||||||
|
let shouldResetCounter: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEMaintenancePolicy {
|
||||||
|
static func plan(
|
||||||
|
cycle: Int,
|
||||||
|
connectedCount: Int,
|
||||||
|
peerRegistryIsEmpty: Bool,
|
||||||
|
elapsedSinceLastAnnounce: TimeInterval,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> BLEMaintenancePlan {
|
||||||
|
BLEMaintenancePlan(
|
||||||
|
shouldSendAnnounce: shouldSendAnnounce(
|
||||||
|
connectedCount: connectedCount,
|
||||||
|
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
|
||||||
|
hasRecentTraffic: hasRecentTraffic,
|
||||||
|
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
|
||||||
|
highDegreeThreshold: highDegreeThreshold
|
||||||
|
),
|
||||||
|
shouldEnsureAdvertising: peerRegistryIsEmpty,
|
||||||
|
shouldRunCleanup: cycle.isMultiple(of: 3),
|
||||||
|
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
|
||||||
|
shouldResetCounter: cycle >= 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldSendAnnounce(
|
||||||
|
connectedCount: Int,
|
||||||
|
elapsedSinceLastAnnounce: TimeInterval,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> Bool {
|
||||||
|
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard connectedCount > 0 else {
|
||||||
|
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
let highDegree = connectedCount >= highDegreeThreshold
|
||||||
|
let base = highDegree ?
|
||||||
|
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
|
||||||
|
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
|
||||||
|
let jitter = highDegree ?
|
||||||
|
TransportConfig.bleConnectedAnnounceJitterDense :
|
||||||
|
TransportConfig.bleConnectedAnnounceJitterSparse
|
||||||
|
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
|
||||||
|
|
||||||
|
return elapsedSinceLastAnnounce >= base + jitterOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLENoisePayloadFactory {
|
||||||
|
static func privateMessage(content: String, messageID: String) -> Data? {
|
||||||
|
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return typedPayload(.privateMessage, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readReceipt(originalMessageID: String) -> Data {
|
||||||
|
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delivered(messageID: String) -> Data {
|
||||||
|
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
|
var typed = Data([type.rawValue])
|
||||||
|
typed.append(payload)
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPendingPrivateMessage: Equatable {
|
||||||
|
let content: String
|
||||||
|
let messageID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLENoiseSessionQueues {
|
||||||
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
|
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
privateMessagesByPeerID.removeAll()
|
||||||
|
typedPayloadsByPeerID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
||||||
|
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
||||||
|
let messages = privateMessagesByPeerID[peerID] ?? []
|
||||||
|
privateMessagesByPeerID.removeValue(forKey: peerID)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
||||||
|
guard !messages.isEmpty else { return }
|
||||||
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||||
|
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||||
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentPlan {
|
||||||
|
let fragmentPackets: [BitchatPacket]
|
||||||
|
let fragmentVersion: UInt8
|
||||||
|
let chunkSize: Int
|
||||||
|
let spacingMs: Int
|
||||||
|
|
||||||
|
var totalFragments: Int {
|
||||||
|
fragmentPackets.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldPauseScanning: Bool {
|
||||||
|
totalFragments > 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEOutboundFragmentPlanner {
|
||||||
|
private static let minimumChunkSize = 64
|
||||||
|
private static let fragmentIDLength = 8
|
||||||
|
|
||||||
|
static func makePlan(
|
||||||
|
for request: BLEOutboundFragmentTransferRequest,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int,
|
||||||
|
fragmentID: Data = randomFragmentID()
|
||||||
|
) -> BLEOutboundFragmentPlan? {
|
||||||
|
guard fragmentID.count == fragmentIDLength,
|
||||||
|
let fullData = request.packet.toBinaryData(padding: request.pad) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizing = sizingPolicy(
|
||||||
|
for: request.packet,
|
||||||
|
requestedMaxChunk: request.maxChunk,
|
||||||
|
defaultChunkSize: defaultChunkSize,
|
||||||
|
bleMaxMTU: bleMaxMTU
|
||||||
|
)
|
||||||
|
|
||||||
|
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
|
||||||
|
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !chunks.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let fragmentRecipient: Data? = {
|
||||||
|
if let directedPeer = request.directedPeer {
|
||||||
|
return Data(hexString: directedPeer.id)
|
||||||
|
}
|
||||||
|
return request.packet.recipientID
|
||||||
|
}()
|
||||||
|
|
||||||
|
let fragmentPackets = chunks.enumerated().map { index, chunk in
|
||||||
|
makeFragmentPacket(
|
||||||
|
original: request.packet,
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: index,
|
||||||
|
total: chunks.count,
|
||||||
|
fragmentData: chunk,
|
||||||
|
fragmentRecipient: fragmentRecipient,
|
||||||
|
fragmentVersion: sizing.fragmentVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEOutboundFragmentPlan(
|
||||||
|
fragmentPackets: fragmentPackets,
|
||||||
|
fragmentVersion: sizing.fragmentVersion,
|
||||||
|
chunkSize: sizing.chunkSize,
|
||||||
|
spacingMs: spacingMs(for: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sizingPolicy(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
requestedMaxChunk: Int?,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int
|
||||||
|
) -> (fragmentVersion: UInt8, chunkSize: Int) {
|
||||||
|
var fragmentVersion: UInt8 = 1
|
||||||
|
var calculatedChunk = defaultChunkSize
|
||||||
|
|
||||||
|
if let route = packet.route, !route.isEmpty {
|
||||||
|
fragmentVersion = 2
|
||||||
|
let routeSize = 1 + (route.count * 8)
|
||||||
|
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
|
||||||
|
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
fragmentVersion: fragmentVersion,
|
||||||
|
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeFragmentPacket(
|
||||||
|
original packet: BitchatPacket,
|
||||||
|
fragmentID: Data,
|
||||||
|
index: Int,
|
||||||
|
total: Int,
|
||||||
|
fragmentData: Data,
|
||||||
|
fragmentRecipient: Data?,
|
||||||
|
fragmentVersion: UInt8
|
||||||
|
) -> BitchatPacket {
|
||||||
|
var payload = Data()
|
||||||
|
payload.append(fragmentID)
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||||
|
payload.append(packet.type)
|
||||||
|
payload.append(fragmentData)
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: MessageType.fragment.rawValue,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: fragmentRecipient,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: packet.ttl,
|
||||||
|
version: fragmentVersion,
|
||||||
|
route: packet.route,
|
||||||
|
isRSR: packet.isRSR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
|
||||||
|
if request.directedPeer != nil || request.packet.recipientID != nil {
|
||||||
|
return TransportConfig.bleFragmentSpacingDirectedMs
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransportConfig.bleFragmentSpacingMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func randomFragmentID() -> Data {
|
||||||
|
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferRequest {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let pad: Bool
|
||||||
|
let maxChunk: Int?
|
||||||
|
let directedPeer: PeerID?
|
||||||
|
let transferId: String?
|
||||||
|
|
||||||
|
var resolvedTransferId: String? {
|
||||||
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
|
return transferId ?? packet.payload.sha256Hex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferScheduler {
|
||||||
|
enum QueuePosition {
|
||||||
|
case front
|
||||||
|
case back
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubmitResult {
|
||||||
|
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||||
|
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CancelResult {
|
||||||
|
case active(transferId: String, workItems: [DispatchWorkItem])
|
||||||
|
case pending(transferId: String)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SentResult: Equatable {
|
||||||
|
case progress(sentFragments: Int, totalFragments: Int)
|
||||||
|
case complete(sentFragments: Int, totalFragments: Int)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveTransferState {
|
||||||
|
let totalFragments: Int
|
||||||
|
var sentFragments: Int
|
||||||
|
var workItems: [DispatchWorkItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||||
|
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
var activeCount: Int {
|
||||||
|
activeTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount: Int {
|
||||||
|
pendingTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
||||||
|
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||||
|
activeTransfers.removeAll()
|
||||||
|
pendingTransfers.removeAll()
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func submit(
|
||||||
|
_ request: BLEOutboundFragmentTransferRequest,
|
||||||
|
maxConcurrentTransfers: Int
|
||||||
|
) -> SubmitResult {
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
return .start(request: request, reservedTransferId: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.append(request)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .back)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .front)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
return .start(request: request, reservedTransferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func activateReservedTransfer(
|
||||||
|
id transferId: String,
|
||||||
|
totalFragments: Int,
|
||||||
|
workItems: [DispatchWorkItem]
|
||||||
|
) -> Bool {
|
||||||
|
guard activeTransfers[transferId] != nil else { return false }
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(
|
||||||
|
totalFragments: totalFragments,
|
||||||
|
sentFragments: 0,
|
||||||
|
workItems: workItems
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
||||||
|
guard var state = activeTransfers[transferId] else { return false }
|
||||||
|
state.workItems = workItems
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)?.workItems
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActive(_ transferId: String) -> Bool {
|
||||||
|
activeTransfers[transferId] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
||||||
|
if let active = activeTransfers.removeValue(forKey: transferId) {
|
||||||
|
return .active(transferId: transferId, workItems: active.workItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
||||||
|
pendingTransfers.remove(at: pendingIndex)
|
||||||
|
return .pending(transferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .missing
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markFragmentSent(transferId: String) -> SentResult {
|
||||||
|
guard var state = activeTransfers[transferId] else { return .missing }
|
||||||
|
|
||||||
|
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
||||||
|
let isComplete = state.sentFragments >= state.totalFragments
|
||||||
|
|
||||||
|
if isComplete {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)
|
||||||
|
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
||||||
|
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
||||||
|
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var results: [SubmitResult] = []
|
||||||
|
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||||
|
let request = pendingTransfers.removeFirst()
|
||||||
|
availableSlots -= 1
|
||||||
|
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
results.append(.start(request: request, reservedTransferId: nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
blockedFront.append(request)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
results.append(.start(request: request, reservedTransferId: transferId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !blockedFront.isEmpty {
|
||||||
|
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundLinkPlan: Equatable {
|
||||||
|
let directedPeerHint: PeerID?
|
||||||
|
let fragmentChunkSize: Int?
|
||||||
|
let selectedLinks: BLEFanoutSelection
|
||||||
|
let shouldSpoolDirectedPacket: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEOutboundLinkPlanner {
|
||||||
|
static func plan(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
dataCount: Int,
|
||||||
|
peripheralIDs: [String],
|
||||||
|
peripheralWriteLimits: [Int],
|
||||||
|
centralIDs: [String],
|
||||||
|
centralNotifyLimits: [Int],
|
||||||
|
ingressRecord: BLEIngressLinkRecord?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID>,
|
||||||
|
directedOnlyPeer: PeerID?
|
||||||
|
) -> BLEOutboundLinkPlan {
|
||||||
|
if let minLimit = minimumLinkLimit(
|
||||||
|
peripheralWriteLimits: peripheralWriteLimits,
|
||||||
|
centralNotifyLimits: centralNotifyLimits
|
||||||
|
), packet.type != MessageType.fragment.rawValue,
|
||||||
|
dataCount > minLimit {
|
||||||
|
return BLEOutboundLinkPlan(
|
||||||
|
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
|
||||||
|
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
||||||
|
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
|
||||||
|
shouldSpoolDirectedPacket: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
||||||
|
let selectedLinks = BLEFanoutSelector.selectLinks(
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
ingressLink: ingressRecord?.link,
|
||||||
|
excludedLinks: excludedLinks,
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
packetType: packet.type,
|
||||||
|
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BLEOutboundLinkPlan(
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
fragmentChunkSize: nil,
|
||||||
|
selectedLinks: selectedLinks,
|
||||||
|
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
selectedLinks: selectedLinks,
|
||||||
|
packetType: packet.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
|
||||||
|
if let explicitPeer { return explicitPeer }
|
||||||
|
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
|
||||||
|
return recipient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
|
||||||
|
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldSpoolDirectedPacket(
|
||||||
|
directedPeerHint: PeerID?,
|
||||||
|
selectedLinks: BLEFanoutSelection,
|
||||||
|
packetType: UInt8
|
||||||
|
) -> Bool {
|
||||||
|
guard directedPeerHint != nil,
|
||||||
|
selectedLinks.peripheralIDs.isEmpty,
|
||||||
|
selectedLinks.centralIDs.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return packetType == MessageType.noiseEncrypted.rawValue ||
|
||||||
|
packetType == MessageType.noiseHandshake.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPendingNotification<Target> {
|
||||||
|
let data: Data
|
||||||
|
let targets: [Target]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundNotificationBuffer<Target> {
|
||||||
|
enum EnqueueResult {
|
||||||
|
case enqueued(count: Int)
|
||||||
|
case full(count: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notifications: [BLEPendingNotification<Target>] = []
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
notifications.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
notifications.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
notifications.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
|
||||||
|
guard notifications.count < capCount else {
|
||||||
|
return .full(count: notifications.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.append(BLEPendingNotification(data: data, targets: targets))
|
||||||
|
return .enqueued(count: notifications.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeAll() -> [BLEPendingNotification<Target>] {
|
||||||
|
let pending = notifications
|
||||||
|
notifications.removeAll()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
|
||||||
|
guard !pending.isEmpty else { return }
|
||||||
|
notifications.insert(contentsOf: pending, at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEOutboundPacketPolicy {
|
||||||
|
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
||||||
|
|
||||||
|
static func messageID(for packet: BitchatPacket) -> String {
|
||||||
|
BLEIngressLinkRegistry.messageID(for: packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
||||||
|
switch MessageType(rawValue: packetType) {
|
||||||
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
|
return true
|
||||||
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||||
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
|
switch messageType {
|
||||||
|
case .fragment:
|
||||||
|
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||||
|
case .fileTransfer:
|
||||||
|
return .fileTransfer
|
||||||
|
default:
|
||||||
|
return .high
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
||||||
|
max(64, limit - fragmentFrameOverhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fragmentTotalCount(from payload: Data) -> Int {
|
||||||
|
guard payload.count >= 12 else { return Int(UInt16.max) }
|
||||||
|
let totalHigh = Int(payload[10])
|
||||||
|
let totalLow = Int(payload[11])
|
||||||
|
let total = (totalHigh << 8) | totalLow
|
||||||
|
return max(total, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundWritePriority: Comparable {
|
||||||
|
let level: Int
|
||||||
|
let suborder: Int
|
||||||
|
|
||||||
|
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
|
||||||
|
|
||||||
|
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
|
||||||
|
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
|
||||||
|
}
|
||||||
|
|
||||||
|
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
|
||||||
|
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
|
||||||
|
|
||||||
|
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
|
||||||
|
if lhs.level != rhs.level { return lhs.level < rhs.level }
|
||||||
|
return lhs.suborder < rhs.suborder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPendingWrite {
|
||||||
|
let priority: BLEOutboundWritePriority
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundWriteBuffer {
|
||||||
|
enum EnqueueResult {
|
||||||
|
case enqueued(trimmedBytes: Int, remainingBytes: Int)
|
||||||
|
case oversized(bytes: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
|
||||||
|
|
||||||
|
var peripheralIDs: [String] {
|
||||||
|
Array(writesByPeripheralID.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
writesByPeripheralID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enqueue(
|
||||||
|
data: Data,
|
||||||
|
for peripheralID: String,
|
||||||
|
priority: BLEOutboundWritePriority,
|
||||||
|
capBytes: Int
|
||||||
|
) -> EnqueueResult {
|
||||||
|
guard data.count <= capBytes else {
|
||||||
|
return .oversized(bytes: data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
let item = BLEPendingWrite(priority: priority, data: data)
|
||||||
|
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
|
||||||
|
queue.insert(item, at: insertIndex)
|
||||||
|
|
||||||
|
var total = queue.reduce(0) { $0 + $1.data.count }
|
||||||
|
var trimmedBytes = 0
|
||||||
|
|
||||||
|
while total > capBytes && !queue.isEmpty {
|
||||||
|
let removed = queue.removeLast()
|
||||||
|
trimmedBytes += removed.data.count
|
||||||
|
total -= removed.data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
||||||
|
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
||||||
|
let items = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
writesByPeripheralID[peripheralID] = nil
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
var existing = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
existing.insert(contentsOf: items, at: 0)
|
||||||
|
writesByPeripheralID[peripheralID] = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPacketFreshnessPolicy {
|
||||||
|
static let defaultMaxAgeSeconds: TimeInterval = 900
|
||||||
|
|
||||||
|
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
|
||||||
|
guard let recipientID else { return true }
|
||||||
|
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isStale(
|
||||||
|
timestampMilliseconds: UInt64,
|
||||||
|
now: Date,
|
||||||
|
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
|
||||||
|
) -> Bool {
|
||||||
|
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
|
||||||
|
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
|
||||||
|
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
|
||||||
|
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
|
||||||
|
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeerEventDebouncer {
|
||||||
|
private var lastEmitByPeer: [PeerID: Date] = [:]
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
lastEmitByPeer.count
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
|
||||||
|
if let lastEmit = lastEmitByPeer[peerID],
|
||||||
|
now.timeIntervalSince(lastEmit) < minimumInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEmitByPeer[peerID] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
lastEmitByPeer.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPeerPublishDecision: Equatable {
|
||||||
|
case publishNow
|
||||||
|
case schedule(delay: TimeInterval)
|
||||||
|
case skip
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerPublishCoalescer {
|
||||||
|
private var lastPublishAt: Date
|
||||||
|
private var publishPending: Bool
|
||||||
|
private let minimumInterval: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
lastPublishAt: Date = .distantPast,
|
||||||
|
publishPending: Bool = false,
|
||||||
|
minimumInterval: TimeInterval = 0.1
|
||||||
|
) {
|
||||||
|
self.lastPublishAt = lastPublishAt
|
||||||
|
self.publishPending = publishPending
|
||||||
|
self.minimumInterval = minimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
|
||||||
|
let elapsed = now.timeIntervalSince(lastPublishAt)
|
||||||
|
if elapsed >= minimumInterval {
|
||||||
|
lastPublishAt = now
|
||||||
|
return .publishNow
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !publishPending else {
|
||||||
|
return .skip
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPending = true
|
||||||
|
return .schedule(delay: minimumInterval - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func scheduledPublishFired(now: Date) {
|
||||||
|
lastPublishAt = now
|
||||||
|
publishPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeerInfo: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
var nickname: String
|
||||||
|
var isConnected: Bool
|
||||||
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
var isVerifiedNickname: Bool
|
||||||
|
var lastSeen: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
|
let isNewPeer: Bool
|
||||||
|
let wasDisconnected: Bool
|
||||||
|
let previousNickname: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerLinkPresence: Equatable {
|
||||||
|
var hasPeripheral: Bool
|
||||||
|
var hasCentral: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERemovedPeer: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerConnectivityChanges: Equatable {
|
||||||
|
var disconnectedPeerIDs: [PeerID] = []
|
||||||
|
var removedPeers: [BLERemovedPeer] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerRegistry {
|
||||||
|
private var peers: [PeerID: BLEPeerInfo] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
peers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
peers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var peerIDs: [PeerID] {
|
||||||
|
Array(peers.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedCount: Int {
|
||||||
|
peers.values.filter(\.isConnected).count
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedPeerIDs: [PeerID] {
|
||||||
|
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedRoutingData: [Data] {
|
||||||
|
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshotByID: [PeerID: BLEPeerInfo] {
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
peers.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ info: BLEPeerInfo) {
|
||||||
|
peers[info.peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
peers[peerID.toShort()]?.isConnected ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||||
|
let shortID = peerID.toShort()
|
||||||
|
let meshAttached = connectedCount > 0
|
||||||
|
guard let info = peers[shortID] else { return false }
|
||||||
|
if info.isConnected { return true }
|
||||||
|
guard meshAttached else { return false }
|
||||||
|
|
||||||
|
let retention: TimeInterval = info.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
return now.timeIntervalSince(info.lastSeen) <= retention
|
||||||
|
}
|
||||||
|
|
||||||
|
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||||
|
guard let peer = peers[peerID] else { return nil }
|
||||||
|
if connectedOnly && !peer.isConnected { return nil }
|
||||||
|
return peer.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
|
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||||
|
let snapshot = Array(peers.values)
|
||||||
|
let resolvedNames = PeerDisplayNameResolver.resolve(
|
||||||
|
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
||||||
|
selfNickname: selfNickname
|
||||||
|
)
|
||||||
|
return snapshot.map { info in
|
||||||
|
TransportPeerSnapshot(
|
||||||
|
peerID: info.peerID,
|
||||||
|
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||||
|
isConnected: info.isConnected,
|
||||||
|
noisePublicKey: info.noisePublicKey,
|
||||||
|
lastSeen: info.lastSeen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||||
|
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||||
|
} || selfNickname == info.nickname
|
||||||
|
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
|
guard var info = peers[peerID] else { return }
|
||||||
|
info.isConnected = false
|
||||||
|
peers[peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
||||||
|
guard var peer = peers[peerID] else { return }
|
||||||
|
peer.lastSeen = date
|
||||||
|
peers[peerID] = peer
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsertVerifiedAnnounce(
|
||||||
|
peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
noisePublicKey: Data,
|
||||||
|
signingPublicKey: Data?,
|
||||||
|
isConnected: Bool,
|
||||||
|
now: Date
|
||||||
|
) -> BLEPeerAnnounceUpdate {
|
||||||
|
let existing = peers[peerID]
|
||||||
|
let update = BLEPeerAnnounceUpdate(
|
||||||
|
isNewPeer: existing == nil,
|
||||||
|
wasDisconnected: existing?.isConnected == false,
|
||||||
|
previousNickname: existing?.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
peers[peerID] = BLEPeerInfo(
|
||||||
|
peerID: existing?.peerID ?? peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: isConnected,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now
|
||||||
|
)
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reconcileConnectivity(
|
||||||
|
now: Date,
|
||||||
|
linkStates: [PeerID: BLEPeerLinkPresence]
|
||||||
|
) -> BLEPeerConnectivityChanges {
|
||||||
|
var changes = BLEPeerConnectivityChanges()
|
||||||
|
|
||||||
|
for (peerID, peer) in Array(peers) {
|
||||||
|
let age = now.timeIntervalSince(peer.lastSeen)
|
||||||
|
let retention: TimeInterval = peer.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
|
||||||
|
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||||
|
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
||||||
|
if !state.hasPeripheral && !state.hasCentral {
|
||||||
|
var updated = peer
|
||||||
|
updated.isConnected = false
|
||||||
|
peers[peerID] = updated
|
||||||
|
changes.disconnectedPeerIDs.append(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !peer.isConnected && age > retention {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPeerSenderDisplayName {
|
||||||
|
static func resolveKnownPeer(
|
||||||
|
peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo],
|
||||||
|
allowConnectedUnverified: Bool
|
||||||
|
) -> String? {
|
||||||
|
if peerID == localPeerID {
|
||||||
|
return localNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let info = peers[peerID] else { return nil }
|
||||||
|
|
||||||
|
if info.isVerifiedNickname {
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: info.nickname,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowConnectedUnverified, info.isConnected {
|
||||||
|
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: displayName,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func anonymousNickname(for peerID: PeerID) -> String {
|
||||||
|
"anon" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func collisionResolvedName(
|
||||||
|
displayName: String,
|
||||||
|
collisionNickname: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo]
|
||||||
|
) -> String {
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
|
||||||
|
} || localNickname == collisionNickname
|
||||||
|
|
||||||
|
guard hasCollision else { return displayName }
|
||||||
|
return displayName + "#" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPublicMessageAcceptance: Equatable {
|
||||||
|
let shouldTrackForSync: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessageRejection: Equatable {
|
||||||
|
case selfEcho
|
||||||
|
case staleBroadcast(ageSeconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessageDecision: Equatable {
|
||||||
|
case accept(BLEPublicMessageAcceptance)
|
||||||
|
case reject(BLEPublicMessageRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessagePolicy {
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
now: Date
|
||||||
|
) -> BLEPublicMessageDecision {
|
||||||
|
if peerID == localPeerID && packet.ttl != 0 {
|
||||||
|
return .reject(.selfEcho)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||||
|
if isBroadcast,
|
||||||
|
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||||
|
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .accept(BLEPublicMessageAcceptance(
|
||||||
|
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEReceivedPacketContext: Equatable {
|
||||||
|
let senderID: PeerID
|
||||||
|
let messageID: String
|
||||||
|
let messageType: MessageType?
|
||||||
|
let shouldDeduplicate: Bool
|
||||||
|
let logsHandlingDetails: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEReceivePipeline {
|
||||||
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||||
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||||
|
let messageType = MessageType(rawValue: packet.type)
|
||||||
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||||
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||||
|
|
||||||
|
return BLEReceivedPacketContext(
|
||||||
|
senderID: senderID,
|
||||||
|
messageID: messageID,
|
||||||
|
messageType: messageType,
|
||||||
|
shouldDeduplicate: shouldDeduplicate,
|
||||||
|
logsHandlingDetails: messageType != .announce
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
||||||
|
connectedPeerCount > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relayDecision(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
senderID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
degree: Int,
|
||||||
|
highDegreeThreshold: Int
|
||||||
|
) -> RelayDecision {
|
||||||
|
RelayController.decide(
|
||||||
|
ttl: packet.ttl,
|
||||||
|
senderIsSelf: senderID == localPeerID,
|
||||||
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||||
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
|
degree: degree,
|
||||||
|
highDegreeThreshold: highDegreeThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERecentTrafficTracker: Equatable {
|
||||||
|
private var packetTimestamps: [Date] = []
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
packetTimestamps.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
packetTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func recordPacket(at now: Date) {
|
||||||
|
packetTimestamps.append(now)
|
||||||
|
prune(at: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||||
|
let cutoff = now.addingTimeInterval(-seconds)
|
||||||
|
return packetTimestamps.contains { $0 >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func prune(at now: Date) {
|
||||||
|
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
||||||
|
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
||||||
|
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
||||||
|
}
|
||||||
|
packetTimestamps.removeAll { $0 < cutoff }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLERouteForwardingPlan {
|
||||||
|
let shouldSuppressFloodRelay: Bool
|
||||||
|
let forwardPacket: BitchatPacket?
|
||||||
|
let nextHop: PeerID?
|
||||||
|
|
||||||
|
static let allowFloodRelay = BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: false,
|
||||||
|
forwardPacket: nil,
|
||||||
|
nextHop: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
static let suppressFloodRelay = BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: true,
|
||||||
|
forwardPacket: nil,
|
||||||
|
nextHop: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
|
||||||
|
BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: true,
|
||||||
|
forwardPacket: packet,
|
||||||
|
nextHop: nextHop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERouteForwardingPolicy {
|
||||||
|
static func plan(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
localRoutingData: Data?,
|
||||||
|
routingPeer: (Data) -> PeerID?,
|
||||||
|
isPeerConnected: (PeerID) -> Bool
|
||||||
|
) -> BLERouteForwardingPlan {
|
||||||
|
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||||
|
return .suppressFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let route = packet.route, !route.isEmpty else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard packet.ttl > 1 else {
|
||||||
|
return .suppressFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let localRoutingData else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let localIndex = route.firstIndex(of: localRoutingData) else {
|
||||||
|
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if localIndex == route.count - 1 {
|
||||||
|
guard let destinationPeer = PeerID(hexData: packet.recipientID),
|
||||||
|
isPeerConnected(destinationPeer) else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
return .forward(relayed(packet), to: destinationPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return forward(
|
||||||
|
packet,
|
||||||
|
toRouteData: route[localIndex + 1],
|
||||||
|
routingPeer: routingPeer,
|
||||||
|
isPeerConnected: isPeerConnected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func forward(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
toRouteData routeData: Data,
|
||||||
|
routingPeer: (Data) -> PeerID?,
|
||||||
|
isPeerConnected: (PeerID) -> Bool
|
||||||
|
) -> BLERouteForwardingPlan {
|
||||||
|
guard let nextPeer = routingPeer(routeData),
|
||||||
|
isPeerConnected(nextPeer) else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
return .forward(relayed(packet), to: nextPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
var relayPacket = packet
|
||||||
|
relayPacket.ttl = packet.ttl - 1
|
||||||
|
return relayPacket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEScanDutyPlan: Equatable {
|
||||||
|
case continuous
|
||||||
|
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEScanDutyPolicy {
|
||||||
|
static func plan(
|
||||||
|
dutyEnabled: Bool,
|
||||||
|
appIsActive: Bool,
|
||||||
|
connectedCount: Int,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> BLEScanDutyPlan {
|
||||||
|
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
|
||||||
|
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
|
||||||
|
|
||||||
|
guard shouldDutyCycle else {
|
||||||
|
return .continuous
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectedCount >= highDegreeThreshold {
|
||||||
|
return .dutyCycle(
|
||||||
|
onDuration: TransportConfig.bleDutyOnDurationDense,
|
||||||
|
offDuration: TransportConfig.bleDutyOffDurationDense
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .dutyCycle(
|
||||||
|
onDuration: TransportConfig.bleDutyOnDuration,
|
||||||
|
offDuration: TransportConfig.bleDutyOffDuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEScheduledRelayStore {
|
||||||
|
private var relays: [String: DispatchWorkItem] = [:]
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
relays.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
relays.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
|
||||||
|
relays[messageID] = workItem
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func remove(messageID: String) -> DispatchWorkItem? {
|
||||||
|
relays.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func cancel(messageID: String) -> Bool {
|
||||||
|
guard let workItem = relays.removeValue(forKey: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
workItem.cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancelAll() {
|
||||||
|
relays.values.forEach { $0.cancel() }
|
||||||
|
relays.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
|
||||||
|
if relays.count > maxCount {
|
||||||
|
relays.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLESelfBroadcastTracker {
|
||||||
|
private struct Entry {
|
||||||
|
let messageID: String
|
||||||
|
let sentAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entriesByDedupID: [String: Entry] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
entriesByDedupID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
entriesByDedupID.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
|
||||||
|
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
|
||||||
|
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prune(before cutoff: Date) {
|
||||||
|
guard !entriesByDedupID.isEmpty else { return }
|
||||||
|
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
entriesByDedupID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dedupID(for packet: BitchatPacket) -> String {
|
||||||
|
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1158
-2188
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLESubscriptionAnnounceDecision: Equatable {
|
||||||
|
case allowed
|
||||||
|
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLESubscriptionAnnounceLimiter {
|
||||||
|
private struct State {
|
||||||
|
var lastAnnounceTime: Date
|
||||||
|
var attemptCount: Int
|
||||||
|
var currentBackoffSeconds: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private var states: [String: State] = [:]
|
||||||
|
|
||||||
|
var trackedCentralCount: Int {
|
||||||
|
states.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
states.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
|
||||||
|
pruneStaleEntries(now: now)
|
||||||
|
|
||||||
|
guard let existing = states[centralID] else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
|
||||||
|
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let newAttemptCount = existing.attemptCount + 1
|
||||||
|
let newBackoff = min(
|
||||||
|
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
|
||||||
|
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
||||||
|
)
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: newAttemptCount,
|
||||||
|
currentBackoffSeconds: newBackoff
|
||||||
|
)
|
||||||
|
|
||||||
|
return .rateLimited(
|
||||||
|
backoffSeconds: existing.currentBackoffSeconds,
|
||||||
|
attemptCount: existing.attemptCount,
|
||||||
|
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: 1,
|
||||||
|
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func pruneStaleEntries(now: Date) {
|
||||||
|
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
|
||||||
|
states = states.filter { _, state in
|
||||||
|
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -191,5 +191,22 @@ 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Result of command processing
|
/// Result of command processing
|
||||||
enum CommandResult {
|
enum CommandResult {
|
||||||
@@ -27,6 +28,7 @@ struct CommandGeoParticipant {
|
|||||||
protocol CommandContextProvider: AnyObject {
|
protocol CommandContextProvider: AnyObject {
|
||||||
// MARK: - State Properties
|
// MARK: - State Properties
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var blockedUsers: Set<String> { get }
|
var blockedUsers: Set<String> { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||||
@@ -74,7 +76,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||||
let inGeoPublic: Bool = {
|
let inGeoPublic: Bool = {
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .mesh: return false
|
case .mesh: return false
|
||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
@@ -134,7 +136,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleWho() -> CommandResult {
|
private func handleWho() -> CommandResult {
|
||||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||||
@@ -166,7 +168,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.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(command) <nickname>")
|
return .error(message: "usage: /\(command) <nickname>")
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleBlock(_ args: String) -> CommandResult {
|
private func handleBlock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
@@ -284,7 +286,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleUnblock(_ args: String) -> CommandResult {
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /unblock <nickname>")
|
return .error(message: "usage: /unblock <nickname>")
|
||||||
}
|
}
|
||||||
@@ -311,7 +313,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
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,4 +1,5 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,25 @@ import Combine
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Tor
|
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.
|
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||||
///
|
///
|
||||||
/// Behavior:
|
/// Behavior:
|
||||||
@@ -25,18 +44,27 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
static let shared = GeohashPresenceService()
|
static let shared = GeohashPresenceService()
|
||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: Timer?
|
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||||
private let idBridge = NostrIdentityBridge()
|
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
|
// MARK: - Constants
|
||||||
|
|
||||||
// Loop interval range in seconds
|
// Loop interval range in seconds
|
||||||
private let loopMinInterval: TimeInterval = 40.0
|
private let loopMinInterval: TimeInterval
|
||||||
private let loopMaxInterval: TimeInterval = 80.0
|
private let loopMaxInterval: TimeInterval
|
||||||
|
|
||||||
// Per-broadcast decorrelation delay range in seconds
|
// Per-broadcast decorrelation delay range in seconds
|
||||||
private let burstMinDelay: TimeInterval = 2.0
|
private let burstMinDelay: TimeInterval
|
||||||
private let burstMaxDelay: TimeInterval = 5.0
|
private let burstMaxDelay: TimeInterval
|
||||||
|
|
||||||
// Privacy: Only broadcast to these levels
|
// Privacy: Only broadcast to these levels
|
||||||
private let allowedPrecisions: Set<Int> = [
|
private let allowedPrecisions: Set<Int> = [
|
||||||
@@ -46,6 +74,74 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private init() {
|
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()
|
setupObservers()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +153,7 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
LocationStateManager.shared.$availableChannels
|
locationChanges
|
||||||
.dropFirst()
|
.dropFirst()
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.handleLocationChange()
|
self?.handleLocationChange()
|
||||||
@@ -65,28 +161,28 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
|
|
||||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
torReadyPublisher
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.handleConnectivityChange()
|
self?.handleConnectivityChange()
|
||||||
}
|
}
|
||||||
.store(in: &subscriptions)
|
.store(in: &subscriptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||||
// to announce presence in the new zone, then reset the loop.
|
// to announce presence in the new zone, then reset the loop.
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
// Small delay to allow location state to settle
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
self?.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||||
// If we were waiting for network, do it now
|
// If we were waiting for network, do it now
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||||
@@ -94,33 +190,33 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
self?.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||||
defer { scheduleNextHeartbeat() }
|
defer { scheduleNextHeartbeat() }
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard TorManager.shared.isReady else {
|
guard torIsReady() else {
|
||||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||||
if !TorManager.shared.isForeground() {
|
if !torIsForeground() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get channels
|
// 2. Get channels
|
||||||
let channels = LocationStateManager.shared.availableChannels
|
let channels = availableChannelsProvider()
|
||||||
guard !channels.isEmpty else { return }
|
guard !channels.isEmpty else { return }
|
||||||
|
|
||||||
// 3. Filter and broadcast
|
// 3. Filter and broadcast
|
||||||
@@ -136,16 +232,16 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
// Random delay for decorrelation
|
// Random delay for decorrelation
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
await self.sleeper(nanoseconds)
|
||||||
|
|
||||||
self.broadcastPresence(for: channel.geohash)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func broadcastPresence(for geohash: String) {
|
func broadcastPresence(for geohash: String) {
|
||||||
do {
|
do {
|
||||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
guard let identity = try? deriveIdentity(geohash) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +251,10 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Send via RelayManager
|
// Send via RelayManager
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
toGeohash: geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
if !targetRelays.isEmpty {
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
relaySender(event, targetRelays)
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,76 +7,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
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 {
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
|
||||||
func getIdentityKey(forKey key: String) -> Data?
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool
|
|
||||||
func deleteAllKeychainData() -> Bool
|
|
||||||
|
|
||||||
func secureClear(_ data: inout Data)
|
|
||||||
func secureClear(_ string: inout String)
|
|
||||||
|
|
||||||
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)
|
|
||||||
/// Save data with a custom service name
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
|
||||||
/// Load data from a custom service
|
|
||||||
func load(key: String, service: String) -> Data?
|
|
||||||
/// Delete data from a custom service
|
|
||||||
func delete(key: String, service: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
|
|||||||
@@ -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, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if let nick = nickname?.trimmedOrNilIfEmpty {
|
||||||
return "\(nick)#\(suffix)"
|
return "\(nick)#\(suffix)"
|
||||||
}
|
}
|
||||||
return "anon#\(suffix)"
|
return "anon#\(suffix)"
|
||||||
@@ -199,8 +199,7 @@ 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) {
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
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
|
||||||
|
|||||||
@@ -5,6 +5,79 @@ 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
|
||||||
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
// MARK: - Private Properties (CoreLocation)
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
private let cl: LocationStateManaging
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder: LocationStateGeocoding
|
||||||
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
|
||||||
@@ -73,6 +146,8 @@ 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
|
||||||
@@ -92,10 +167,30 @@ 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),
|
||||||
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initializePermissionState() {
|
private func initializePermissionState() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
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
|
||||||
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
// MARK: - Public API (Permissions & Location)
|
// MARK: - Public API (Permissions & Location)
|
||||||
|
|
||||||
func enableLocationChannels() {
|
func enableLocationChannels() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
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()
|
||||||
@@ -512,7 +597,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
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmed
|
||||||
.lowercased()
|
.lowercased()
|
||||||
.replacingOccurrences(of: "#", with: "")
|
.replacingOccurrences(of: "#", with: "")
|
||||||
.filter { allowed.contains($0) }
|
.filter { allowed.contains($0) }
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
// Trim and collapse whitespace
|
||||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = simplified.trimmed
|
||||||
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,6 +6,7 @@
|
|||||||
// 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
|
||||||
|
|
||||||
@@ -254,9 +255,8 @@ final class MessageFormattingEngine {
|
|||||||
isSelf: Bool,
|
isSelf: Bool,
|
||||||
isMentioned: Bool
|
isMentioned: Bool
|
||||||
) -> AttributedString {
|
) -> AttributedString {
|
||||||
// For very long content without special tokens, use plain formatting
|
// For very long content, use plain formatting to avoid expensive regex and detector work.
|
||||||
let containsCashu = containsCashuToken(content)
|
if content.isOversizedForRichFormatting() {
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu {
|
|
||||||
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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.)
|
||||||
|
|||||||
@@ -3,6 +3,27 @@ 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.
|
||||||
@@ -17,14 +38,55 @@ 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 = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool {
|
if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
|
||||||
userTorEnabled = stored
|
userTorEnabled = stored
|
||||||
} else {
|
} else {
|
||||||
userTorEnabled = true
|
userTorEnabled = true
|
||||||
@@ -34,16 +96,16 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
let allowed = basePolicyAllowed()
|
let allowed = basePolicyAllowed()
|
||||||
activationAllowed = allowed
|
activationAllowed = allowed
|
||||||
torAutoStartDesired = allowed && userTorEnabled
|
torAutoStartDesired = allowed && userTorEnabled
|
||||||
TorManager.shared.setAutoStartAllowed(torAutoStartDesired)
|
torController.setAutoStartAllowed(torAutoStartDesired)
|
||||||
applyTorState(torDesired: torAutoStartDesired)
|
applyTorState(torDesired: torAutoStartDesired)
|
||||||
if allowed {
|
if allowed {
|
||||||
NostrRelayManager.shared.connect()
|
relayController.connect()
|
||||||
} else {
|
} else {
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// React to location permission changes
|
// React to location permission changes
|
||||||
LocationChannelManager.shared.$permissionState
|
locationPermissionPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -51,7 +113,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// React to mutual favorites changes
|
// React to mutual favorites changes
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
mutualFavoritesPublisher
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.reevaluate()
|
self?.reevaluate()
|
||||||
@@ -62,8 +124,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
|
||||||
UserDefaults.standard.set(enabled, forKey: torPreferenceKey)
|
storage.set(enabled, forKey: torPreferenceKey)
|
||||||
NotificationCenter.default.post(
|
notificationCenter.post(
|
||||||
name: .TorUserPreferenceChanged,
|
name: .TorUserPreferenceChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
userInfo: ["enabled": enabled]
|
userInfo: ["enabled": enabled]
|
||||||
@@ -82,33 +144,33 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
if statusChanged || torChanged {
|
if statusChanged || torChanged {
|
||||||
torAutoStartDesired = torDesired
|
torAutoStartDesired = torDesired
|
||||||
TorManager.shared.setAutoStartAllowed(torDesired)
|
torController.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)
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
NostrRelayManager.shared.connect()
|
relayController.connect()
|
||||||
} else if statusChanged {
|
} else if statusChanged {
|
||||||
NostrRelayManager.shared.disconnect()
|
relayController.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func basePolicyAllowed() -> Bool {
|
private func basePolicyAllowed() -> Bool {
|
||||||
let permOK = LocationChannelManager.shared.permissionState == .authorized
|
let permOK = permissionProvider() == .authorized
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
let hasMutual = !mutualFavoritesProvider().isEmpty
|
||||||
return permOK || hasMutual
|
return permOK || hasMutual
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyTorState(torDesired: Bool) {
|
private func applyTorState(torDesired: Bool) {
|
||||||
TorURLSession.shared.setProxyMode(useTor: torDesired)
|
proxyController.setProxyMode(useTor: torDesired)
|
||||||
if torDesired {
|
if torDesired {
|
||||||
TorManager.shared.startIfNeeded()
|
torController.startIfNeeded()
|
||||||
} else {
|
} else {
|
||||||
TorManager.shared.shutdownCompletely()
|
torController.shutdownCompletely()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
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: "")
|
||||||
|
|
||||||
@@ -17,20 +44,27 @@ 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(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) {
|
init(
|
||||||
|
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 = FavoritesPersistenceService.shared.favorites
|
let favorites = self.dependencies.loadFavorites()
|
||||||
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) }
|
||||||
@@ -40,8 +74,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let favoriteStatusObserver {
|
||||||
|
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
NotificationCenter.default.addObserver(
|
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
|
||||||
forName: .favoriteStatusChanged,
|
forName: .favoriteStatusChanged,
|
||||||
object: nil,
|
object: nil,
|
||||||
queue: nil
|
queue: nil
|
||||||
@@ -52,7 +92,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
|
|
||||||
private func refreshReachablePeers() {
|
private func refreshReachablePeers() {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let favorites = FavoritesPersistenceService.shared.favorites
|
let favorites = dependencies.loadFavorites()
|
||||||
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) }
|
||||||
@@ -66,6 +106,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var eventDelegate: TransportEventDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
@@ -120,7 +161,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
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)
|
||||||
@@ -133,9 +174,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
// Use barrier to synchronize access to readQueue
|
// Use barrier to synchronize access to readQueue
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
queue.async(flags: .barrier) {
|
||||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
self?.processReadQueueIfNeeded()
|
self.processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +184,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
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)
|
||||||
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 {
|
||||||
@@ -159,7 +200,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||||
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)
|
||||||
@@ -229,9 +270,9 @@ extension NostrTransport {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if registerPending {
|
if registerPending {
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
dependencies.registerPendingGiftWrap(event.id)
|
||||||
}
|
}
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
dependencies.sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
/// Must be called within a barrier on `queue`
|
||||||
@@ -249,7 +290,7 @@ extension NostrTransport {
|
|||||||
defer { scheduleNextReadAck() }
|
defer { scheduleNextReadAck() }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
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)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
@@ -260,7 +301,7 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
private func scheduleNextReadAck() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
dependencies.scheduleAfter(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()
|
||||||
@@ -271,12 +312,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 = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = dependencies.favoriteStatusForNoiseKey(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 = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 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)
|
||||||
@@ -14,24 +15,103 @@ 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 {
|
||||||
let env = ProcessInfo.processInfo.environment
|
isRunningTestsProvider()
|
||||||
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 }
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
@@ -64,7 +144,7 @@ final class NotificationService {
|
|||||||
trigger: nil // Deliver immediately
|
trigger: nil // Deliver immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
requestDeliverer.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -96,7 +176,8 @@ 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"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||||
|
let identifier = "network-available"
|
||||||
|
|
||||||
sendLocalNotification(
|
sendLocalNotification(
|
||||||
title: title,
|
title: title,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct NotificationStreamAssembler {
|
struct NotificationStreamAssembler {
|
||||||
@@ -20,6 +21,19 @@ struct NotificationStreamAssembler {
|
|||||||
pendingFrameExpectedLength = 0
|
pendingFrameExpectedLength = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mutating func discardLeadingPaddingIfPresent() -> Bool {
|
||||||
|
guard let first = buffer.first else { return false }
|
||||||
|
guard first != 1 && first != 2 else { return false }
|
||||||
|
let paddingLength = Int(first)
|
||||||
|
guard paddingLength > 0, paddingLength <= buffer.count else { return false }
|
||||||
|
guard buffer.prefix(paddingLength).allSatisfy({ $0 == first }) else { return false }
|
||||||
|
|
||||||
|
buffer.removeFirst(paddingLength)
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
@@ -41,6 +55,9 @@ struct NotificationStreamAssembler {
|
|||||||
while buffer.count >= minimumFramePrefix {
|
while buffer.count >= minimumFramePrefix {
|
||||||
guard let version = buffer.first else { break }
|
guard let version = buffer.first else { break }
|
||||||
guard version == 1 || version == 2 else {
|
guard version == 1 || version == 2 else {
|
||||||
|
if discardLeadingPaddingIfPresent() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
pendingFrameStartedAt = nil
|
pendingFrameStartedAt = nil
|
||||||
pendingFrameExpectedLength = 0
|
pendingFrameExpectedLength = 0
|
||||||
@@ -132,6 +149,11 @@ struct NotificationStreamAssembler {
|
|||||||
let frame = Data(buffer.prefix(frameLength))
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
buffer.removeFirst(frameLength)
|
buffer.removeFirst(frameLength)
|
||||||
|
_ = discardLeadingPaddingIfPresent()
|
||||||
|
}
|
||||||
|
|
||||||
|
if discardLeadingPaddingIfPresent() {
|
||||||
|
return (frames, dropped, didReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct RelayDecision {
|
|||||||
struct RelayController {
|
struct RelayController {
|
||||||
static func decide(ttl: UInt8,
|
static func decide(ttl: UInt8,
|
||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
|
recipientIsSelf: Bool = false,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
isFragment: Bool,
|
||||||
@@ -22,7 +23,7 @@ struct RelayController {
|
|||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttlCap <= 1 || senderIsSelf {
|
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
@@ -11,9 +13,27 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TransportEvent: @unchecked Sendable {
|
||||||
|
case messageReceived(BitchatMessage)
|
||||||
|
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
|
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
case peerConnected(PeerID)
|
||||||
|
case peerDisconnected(PeerID)
|
||||||
|
case peerListUpdated([PeerID])
|
||||||
|
case peerSnapshotsUpdated([TransportPeerSnapshot])
|
||||||
|
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
|
||||||
|
case bluetoothStateUpdated(CBManagerState)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol TransportEventDelegate: AnyObject {
|
||||||
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
// Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code.
|
||||||
|
var eventDelegate: TransportEventDelegate? { get set }
|
||||||
// Peer events (preferred over publishers for UI)
|
// Peer events (preferred over publishers for UI)
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
@@ -83,4 +103,36 @@ protocol TransportPeerEventsDelegate: AnyObject {
|
|||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension BitchatDelegate {
|
||||||
|
@MainActor
|
||||||
|
func receiveTransportEvent(_ event: TransportEvent) {
|
||||||
|
switch event {
|
||||||
|
case .messageReceived(let message):
|
||||||
|
didReceiveMessage(message)
|
||||||
|
case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID):
|
||||||
|
didReceivePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
|
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||||
|
case .peerConnected(let peerID):
|
||||||
|
didConnectToPeer(peerID)
|
||||||
|
case .peerDisconnected(let peerID):
|
||||||
|
didDisconnectFromPeer(peerID)
|
||||||
|
case .peerListUpdated(let peers):
|
||||||
|
didUpdatePeerList(peers)
|
||||||
|
case .peerSnapshotsUpdated:
|
||||||
|
break
|
||||||
|
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||||
|
didUpdateMessageDeliveryStatus(messageID, status: status)
|
||||||
|
case .bluetoothStateUpdated(let state):
|
||||||
|
didUpdateBluetoothState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension BLEService: Transport {}
|
extension BLEService: Transport {}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ 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
|
||||||
@@ -132,9 +133,6 @@ enum TransportConfig {
|
|||||||
static let nostrShortKeyDisplayLength: Int = 8
|
static let nostrShortKeyDisplayLength: Int = 8
|
||||||
static let nostrConvKeyPrefixLength: Int = 16
|
static let nostrConvKeyPrefixLength: Int = 16
|
||||||
|
|
||||||
// Compression
|
|
||||||
static let compressionThresholdBytes: Int = 100
|
|
||||||
|
|
||||||
// Message deduplication
|
// Message deduplication
|
||||||
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
static let messageDedupMaxAgeSeconds: TimeInterval = 300
|
||||||
static let messageDedupMaxCount: Int = 1000
|
static let messageDedupMaxCount: Int = 1000
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
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 {
|
||||||
@@ -127,17 +128,15 @@ final class GossipSyncManager {
|
|||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
|
||||||
|
var types: SyncTypeFlags = .publicMessages
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
types.formUnion(.fragment)
|
||||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
types.formUnion(.fileTransfer)
|
||||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,13 +391,18 @@ final class GossipSyncManager {
|
|||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||||
|
|
||||||
|
var dueTypes: SyncTypeFlags = []
|
||||||
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)
|
dueTypes.formUnion(syncSchedules[index].types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !dueTypes.isEmpty {
|
||||||
|
sendPeriodicSync(for: dueTypes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import struct BitFoundation.BitchatPacket
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Manages outgoing sync requests and validates incoming responses.
|
/// Manages outgoing sync requests and validates incoming responses.
|
||||||
///
|
///
|
||||||
@@ -17,14 +18,21 @@ final class RequestSyncManager {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||||
|
private let responseWindow: TimeInterval
|
||||||
|
private let now: () -> TimeInterval
|
||||||
|
|
||||||
// Allow responses for 30s after request
|
init(
|
||||||
private let responseWindow: TimeInterval = 30.0
|
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.
|
/// Register that we are sending a sync request to a peer.
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
/// - Parameter peerID: The peer we are requesting sync from
|
||||||
func registerRequest(to peerID: PeerID) {
|
func registerRequest(to peerID: PeerID) {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||||
self.pendingRequests[peerID] = now
|
self.pendingRequests[peerID] = now
|
||||||
@@ -46,7 +54,7 @@ final class RequestSyncManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
if now - requestTime > responseWindow {
|
if now - requestTime > responseWindow {
|
||||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
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
|
// We don't remove here because we might receive multiple packets for one request
|
||||||
@@ -59,7 +67,7 @@ final class RequestSyncManager {
|
|||||||
|
|
||||||
/// Periodic cleanup of expired requests
|
/// Periodic cleanup of expired requests
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let originalCount = self.pendingRequests.count
|
let originalCount = self.pendingRequests.count
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||||
@@ -71,4 +79,8 @@ final class RequestSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugPendingRequestCount: Int {
|
||||||
|
queue.sync { pendingRequests.count }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
/// Bitfield describing which message types are covered by a REQUEST_SYNC round.
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
//
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user