mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 17:25:20 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f47121b84 | ||
|
|
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 | ||
|
|
74cf0d89cc | ||
|
|
b4b6aa5ca6 | ||
|
|
1e5a52f39f | ||
|
|
3fc64f6168 | ||
|
|
9964710de2 | ||
|
|
806c420313 | ||
|
|
194dedac43 | ||
|
|
9af46a9ff8 | ||
|
|
da3fcd5a21 | ||
|
|
b282536080 | ||
|
|
e156356c71 | ||
|
|
81a6e18d04 | ||
|
|
4fbbd24021 | ||
|
|
ec54877140 | ||
|
|
293d627c28 | ||
|
|
0c3f84224c | ||
|
|
7323c0b96c | ||
|
|
7bb835ffc9 | ||
|
|
23d63ab4df | ||
|
|
fa3c74f941 | ||
|
|
9bac52051a | ||
|
|
7b9ffe464a | ||
|
|
7b940485d9 | ||
|
|
5a87ee3e62 | ||
|
|
342eabbc00 | ||
|
|
241ce2d52c | ||
|
|
18b56e7393 | ||
|
|
beb04fc887 | ||
|
|
5fcffefa28 | ||
|
|
46ae039587 | ||
|
|
917f7ebe5f | ||
|
|
b47fb736f4 | ||
|
|
ebb5bb6558 | ||
|
|
7cfdcfe174 | ||
|
|
21e0cbc607 | ||
|
|
1b439a543e | ||
|
|
d30a3b14cf | ||
|
|
d03128612d | ||
|
|
ae294aab6d | ||
|
|
07b0e146f2 | ||
|
|
cc5939cb13 | ||
|
|
cada784844 | ||
|
|
b1aeb931bc | ||
|
|
b675738664 | ||
|
|
4091a30f10 | ||
|
|
238311aefb | ||
|
|
6defae71c6 | ||
|
|
b533d9560d | ||
|
|
f41a390a94 | ||
|
|
f83316bd1f | ||
|
|
09818a02ed | ||
|
|
9cd955ae2f | ||
|
|
49b1413d85 | ||
|
|
31be6b83a7 | ||
|
|
1563209797 | ||
|
|
99896bcded | ||
|
|
4b3077169a | ||
|
|
b84c36c6fa | ||
|
|
3eac5858e4 | ||
|
|
10b7c1fd80 | ||
|
|
bf3249aef7 | ||
|
|
95a6ec7315 | ||
|
|
74864472c7 | ||
|
|
9404c03477 | ||
|
|
6faa46a22f | ||
|
|
e02f4327c0 | ||
|
|
ce31d85323 | ||
|
|
b6d8a5b758 | ||
|
|
6630f5a792 | ||
|
|
0f5299a0f5 | ||
|
|
eb3bbfd861 | ||
|
|
a37243e780 | ||
|
|
90b134186b | ||
|
|
3c7e14f49d | ||
|
|
31275856dd | ||
|
|
b6cf44a824 | ||
|
|
b6ae08be60 | ||
|
|
d469704c34 | ||
|
|
c975abf2ff | ||
|
|
aff700a15e | ||
|
|
869d766f8d | ||
|
|
1b4f120014 | ||
|
|
bc312e4aef | ||
|
|
6e7509f2be | ||
|
|
ca06f4d51d | ||
|
|
04f57e8713 | ||
|
|
59c3c4e236 | ||
|
|
e887e04f40 | ||
|
|
151b68a497 | ||
|
|
b4a3ee5777 | ||
|
|
8d19d6d62c | ||
|
|
84fd92ef4b | ||
|
|
f71bd506fd | ||
|
|
ddd7ef5668 | ||
|
|
548a20e77d | ||
|
|
6e231d10c5 | ||
|
|
4c9f6e689e | ||
|
|
7e73b65240 | ||
|
|
f5e5f7b98e | ||
|
|
b15d92ebb5 | ||
|
|
6efe9d02fb | ||
|
|
5a66f03400 | ||
|
|
a221b22691 | ||
|
|
4b38e9a23c | ||
|
|
2686d9c82c | ||
|
|
832e1f006c | ||
|
|
090c4cd919 | ||
|
|
37c2b3a8c2 | ||
|
|
6f1c879237 | ||
|
|
81f5101390 | ||
|
|
d56674706d | ||
|
|
33bcfa3147 | ||
|
|
3a54160793 | ||
|
|
5d2745eae6 | ||
|
|
5138ce4a33 | ||
|
|
07a4997192 | ||
|
|
2d703f4dd9 | ||
|
|
56b9457fcf | ||
|
|
9e57bce5c3 | ||
|
|
6eef030386 | ||
|
|
9d8754099d | ||
|
|
7fae4e71fb | ||
|
|
76d8b3fa7f | ||
|
|
b8a8b940b7 | ||
|
|
7a5ab52f4c | ||
|
|
d2edb651c1 | ||
|
|
858e878959 | ||
|
|
fa0a15cbcf | ||
|
|
3e680f40bf | ||
|
|
ec3c16176e | ||
|
|
2a8de7e048 | ||
|
|
e9dd30ccbf | ||
|
|
af28faa91d | ||
|
|
b33fe8086d | ||
|
|
5495874b5a | ||
|
|
0fca551966 | ||
|
|
0492480598 | ||
|
|
86f46c8b90 | ||
|
|
7057fe75b0 | ||
|
|
873788b24f | ||
|
|
b6dd31b285 | ||
|
|
ff7e5a2c2c | ||
|
|
708a5e33cd | ||
|
|
f893f0605a | ||
|
|
ce33132a0f | ||
|
|
c538837300 | ||
|
|
6888bfa351 | ||
|
|
2fed4b7c31 | ||
|
|
b956e407ff | ||
|
|
477cc7fa05 | ||
|
|
dffce9d63f | ||
|
|
792eaa3166 | ||
|
|
6ff9b59ff2 | ||
|
|
bc04cd0bde | ||
|
|
3fe417395e | ||
|
|
e35bd57c6e | ||
|
|
5aefb05a8b | ||
|
|
e4cbf382ce | ||
|
|
7609c6eeba | ||
|
|
f37acf7e4b | ||
|
|
cf528b0daf | ||
|
|
209ccb4ade | ||
|
|
1876e85f8b | ||
|
|
109b7d0e03 | ||
|
|
09a319fb0f | ||
|
|
97ca55cc54 | ||
|
|
394836f247 | ||
|
|
97fc21c23f | ||
|
|
79bd4af912 | ||
|
|
96c78ef5a2 | ||
|
|
01256f3041 | ||
|
|
2edbe2bbbd | ||
|
|
a91978c10e | ||
|
|
14c4e586fc | ||
|
|
83779240ae | ||
|
|
5034732515 | ||
|
|
b6ce4fae43 | ||
|
|
98fa02cc16 | ||
|
|
0812cafd55 | ||
|
|
450955525c | ||
|
|
475bc70c71 | ||
|
|
af6136c01c | ||
|
|
b839ce5f6c | ||
|
|
0776c9813c | ||
|
|
0dd999af6b | ||
|
|
c3a1af7023 | ||
|
|
880813f256 | ||
|
|
64fb634166 | ||
|
|
13b19fb8eb | ||
|
|
d4967ae9c3 | ||
|
|
435744a977 | ||
|
|
70caa9e24a | ||
|
|
5084f87fe5 | ||
|
|
8f56e4f0fb | ||
|
|
aca44f9f55 | ||
|
|
3f00cf9467 | ||
|
|
40e54a5120 | ||
|
|
7040d9ecb0 | ||
|
|
88bafb41cc |
@@ -7,6 +7,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-relay-data:
|
||||
@@ -17,10 +18,11 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Check for changes
|
||||
|
||||
@@ -5,14 +5,23 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run Swift Tests
|
||||
name: Run Swift Tests (${{ matrix.name }})
|
||||
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:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
@@ -20,8 +29,14 @@ jobs:
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
|
||||
- name: Build the package
|
||||
run: swift build
|
||||
- name: Cache build artifacts
|
||||
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
|
||||
run: swift test --parallel
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
|
||||
@@ -8,6 +8,7 @@ plans/
|
||||
## AI
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
.claude/
|
||||
|
||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||
*.xcscmblueprint
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.4.4
|
||||
MARKETING_VERSION = 1.5.1
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
|
||||
CODE_SIGN_STYLE = Automatic
|
||||
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
|
||||
APP_GROUP_ID = group.chat.bitchat
|
||||
|
||||
@@ -47,7 +47,7 @@ patch-for-macos: backup
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
|
||||
+13
-5
@@ -16,7 +16,8 @@ let package = Package(
|
||||
),
|
||||
],
|
||||
dependencies:[
|
||||
.package(path: "localPackages/Tor"),
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||
],
|
||||
@@ -25,16 +26,19 @@ let package = Package(
|
||||
name: "bitchat",
|
||||
dependencies: [
|
||||
.product(name: "P256K", package: "swift-secp256k1"),
|
||||
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||
.product(name: "BitLogger", package: "BitLogger"),
|
||||
.product(name: "Tor", package: "Tor")
|
||||
.product(name: "Tor", package: "Arti")
|
||||
],
|
||||
path: "bitchat",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"Assets.xcassets",
|
||||
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||
"bitchat.entitlements",
|
||||
"bitchat-macOS.entitlements",
|
||||
"LaunchScreen.storyboard"
|
||||
"LaunchScreen.storyboard",
|
||||
"ViewModels/Extensions/README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localizable.xcstrings")
|
||||
@@ -42,14 +46,18 @@ let package = Package(
|
||||
),
|
||||
.testTarget(
|
||||
name: "bitchatTests",
|
||||
dependencies: ["bitchat"],
|
||||
dependencies: [
|
||||
"bitchat",
|
||||
.product(name: "BitFoundation", package: "BitFoundation")
|
||||
],
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization")
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
||||
|
||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||
|
||||
> [!WARNING]
|
||||
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||
|
||||
## License
|
||||
|
||||
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
Generated
+65
-11
@@ -10,11 +10,12 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -95,6 +96,24 @@
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
};
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 47FF23248747DD7CB666CB91 /* bitchatTests_macOS */;
|
||||
};
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
Localization/PrimaryLocalizationKeys.json,
|
||||
README.md,
|
||||
);
|
||||
target = 6CB97DF2EA57234CB3E563B8 /* bitchatTests_iOS */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
@@ -118,6 +137,10 @@
|
||||
};
|
||||
A6E32D412E762EAE0032EA8A /* bitchatTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
C5E027A82ECCDFE200BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_iOS" target */,
|
||||
C5E027A52ECCDFD700BD6012 /* Exceptions for "bitchatTests" folder in "bitchatTests_macOS" target */,
|
||||
);
|
||||
path = bitchatTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -135,15 +158,16 @@
|
||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
files = (
|
||||
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||
);
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
@@ -203,6 +227,7 @@
|
||||
B1D9136AA0083366353BFA2F /* P256K */,
|
||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||
);
|
||||
productName = bitchat_macOS;
|
||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||
@@ -213,6 +238,7 @@
|
||||
buildConfigurationList = 1C27B5BA3DB46DDF0DBFEF62 /* Build configuration list for PBXNativeTarget "bitchatTests_macOS" */;
|
||||
buildPhases = (
|
||||
5C22AA7B9ACC5A861445C769 /* Sources */,
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -245,6 +271,7 @@
|
||||
buildConfigurationList = 38C4AF6313E5037F25CEF30B /* Build configuration list for PBXNativeTarget "bitchatTests_iOS" */;
|
||||
buildPhases = (
|
||||
865C8403EF02C089369A9FCB /* Sources */,
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -281,6 +308,7 @@
|
||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||
);
|
||||
productName = bitchat_iOS;
|
||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||
@@ -321,7 +349,8 @@
|
||||
packageReferences = (
|
||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 90;
|
||||
projectDirPath = "";
|
||||
@@ -343,6 +372,16 @@
|
||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
|
||||
);
|
||||
};
|
||||
C5E027A42ECCDFD700BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
C5E027A72ECCDFE200BD6012 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
);
|
||||
};
|
||||
CD6E8F32BC38357473954F97 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
files = (
|
||||
@@ -516,6 +555,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = NO;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -526,7 +566,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -576,6 +616,7 @@
|
||||
CODE_SIGNING_REQUIRED = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
INFOPLIST_FILE = bitchat/Info.plist;
|
||||
@@ -586,7 +627,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -622,7 +663,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -714,7 +755,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -875,13 +916,17 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitFoundation;
|
||||
};
|
||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/BitLogger;
|
||||
};
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = localPackages/Tor;
|
||||
relativePath = localPackages/Arti;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
@@ -890,8 +935,8 @@
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.21.1;
|
||||
kind = exactVersion;
|
||||
version = 0.21.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
@@ -902,6 +947,15 @@
|
||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||
productName = P256K;
|
||||
};
|
||||
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
|
||||
productName = BitFoundation;
|
||||
};
|
||||
A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = BitLogger;
|
||||
|
||||
@@ -98,8 +98,8 @@
|
||||
</BuildableProductRunnable>
|
||||
<EnvironmentVariables>
|
||||
<EnvironmentVariable
|
||||
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||
value = ""
|
||||
key = "BITCHAT_LOG_LEVEL"
|
||||
value = "debug"
|
||||
isEnabled = "YES">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
|
||||
@@ -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,101 @@
|
||||
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> = []
|
||||
|
||||
private let teleportedGeoCapacity: Int
|
||||
private var teleportedGeoOrder: [String] = []
|
||||
|
||||
init(teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap) {
|
||||
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||
}
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
let normalized = geohash?.lowercased()
|
||||
if currentGeohash != normalized {
|
||||
clearTeleportedGeo()
|
||||
}
|
||||
currentGeohash = normalized
|
||||
}
|
||||
|
||||
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) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
guard !teleportedGeo.contains(key) else { return }
|
||||
|
||||
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||
teleportedGeoOrder.removeFirst()
|
||||
teleportedGeo.remove(oldest)
|
||||
}
|
||||
|
||||
teleportedGeo.insert(key)
|
||||
teleportedGeoOrder.append(key)
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
teleportedGeo.remove(key)
|
||||
teleportedGeoOrder.removeAll { $0 == key }
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
ordered.append(key)
|
||||
}
|
||||
if ordered.count > teleportedGeoCapacity {
|
||||
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||
}
|
||||
teleportedGeoOrder = ordered
|
||||
teleportedGeo = Set(ordered)
|
||||
}
|
||||
|
||||
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.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")
|
||||
}
|
||||
}
|
||||
+44
-194
@@ -6,7 +6,6 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Tor
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
|
||||
@@ -14,116 +13,48 @@ import UserNotifications
|
||||
struct BitchatApp: App {
|
||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||
static let groupID = "group.\(bundleID)"
|
||||
|
||||
@StateObject private var chatViewModel: ChatViewModel
|
||||
|
||||
@StateObject private var runtime: AppRuntime
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@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)
|
||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||
#endif
|
||||
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
|
||||
init() {
|
||||
let keychain = KeychainManager()
|
||||
let idBridge = self.idBridge
|
||||
_chatViewModel = StateObject(
|
||||
wrappedValue: ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain)
|
||||
)
|
||||
)
|
||||
|
||||
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||
// Warm up georelay directory and refresh if stale (once/day)
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
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 {
|
||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
||||
}
|
||||
#if os(iOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#elseif os(macOS)
|
||||
appDelegate.chatViewModel = chatViewModel
|
||||
#endif
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
runtime.handleOpenURL(url)
|
||||
}
|
||||
#if os(iOS)
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
switch 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
|
||||
}
|
||||
runtime.handleScenePhaseChange(newPhase)
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||
// Check for shared content when app becomes active
|
||||
checkForSharedContent()
|
||||
runtime.handleDidBecomeActiveNotification()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||
// App became active
|
||||
runtime.handleMacDidBecomeActiveNotification()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -132,62 +63,18 @@ struct BitchatApp: App {
|
||||
.windowResizability(.contentSize)
|
||||
#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)
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -196,79 +83,42 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
import AppKit
|
||||
|
||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
chatViewModel?.applicationWillTerminate()
|
||||
runtime?.applicationWillTerminate()
|
||||
}
|
||||
|
||||
|
||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||
return true
|
||||
true
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||
static let shared = NotificationDelegate()
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||
let identifier = response.notification.request.identifier
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
DispatchQueue.main.async {
|
||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
|
||||
|
||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||
let identifier = notification.request.identifier
|
||||
let userInfo = notification.request.content.userInfo
|
||||
|
||||
// Check if this is a private message notification
|
||||
if identifier.hasPrefix("private-") {
|
||||
// Get peer ID from userInfo
|
||||
if let peerID = userInfo["peerID"] as? String {
|
||||
// Don't show notification if the private chat is already open
|
||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||
completionHandler([])
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
var nilIfEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
Task {
|
||||
let options = await self.runtime?.presentationOptions(
|
||||
forNotificationIdentifier: identifier,
|
||||
userInfo: userInfo
|
||||
) ?? [.banner, .sound]
|
||||
completionHandler(options)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
enum ImageUtilsError: Error {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
#if os(iOS)
|
||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#else
|
||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||
#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)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = CGSize(width: size.width * scale, height: size.height * scale)
|
||||
|
||||
// Draw into a new context to get a clean CGImage without metadata
|
||||
UIGraphicsBeginImageContextWithOptions(newSize, true, 1.0)
|
||||
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||
let rendered = UIGraphicsGetImageFromCurrentImageContext()
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
|
||||
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
|
||||
let size = image.size
|
||||
let maxSide = max(size.width, size.height)
|
||||
guard maxSide > maxDimension else { return image }
|
||||
let scale = maxDimension / maxSide
|
||||
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
|
||||
let scaledImage = NSImage(size: newSize)
|
||||
scaledImage.lockFocus()
|
||||
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||
from: NSRect(origin: .zero, size: size),
|
||||
operation: .copy,
|
||||
fraction: 1.0)
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||
|
||||
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)
|
||||
return directory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private static func applicationFilesDirectory() throws -> URL {
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitLogger
|
||||
|
||||
/// Controls playback for a single voice note and coordinates exclusive playback across the app.
|
||||
final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlayerDelegate {
|
||||
@Published private(set) var isPlaying: Bool = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 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 timer: Timer?
|
||||
private var url: URL
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
super.init()
|
||||
// Don't load anything eagerly - wait until user interaction or view is fully displayed
|
||||
}
|
||||
|
||||
func loadDuration() {
|
||||
guard duration == 0 else { return }
|
||||
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: self.url)
|
||||
let loadedDuration = player.duration
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self, self.duration == 0 else { return }
|
||||
self.duration = loadedDuration
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to load audio duration: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
|
||||
func replaceURL(_ url: URL) {
|
||||
guard url != self.url else { return }
|
||||
stop()
|
||||
self.url = url
|
||||
player = nil
|
||||
duration = 0
|
||||
// Duration will be loaded on demand when needed
|
||||
}
|
||||
|
||||
func togglePlayback() {
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func play() {
|
||||
guard ensurePlayerReady() else { return }
|
||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
||||
player?.play()
|
||||
startTimer()
|
||||
updateProgress()
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player?.stop()
|
||||
player?.currentTime = 0
|
||||
stopTimer()
|
||||
updateProgress()
|
||||
isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
|
||||
func seek(to fraction: Double) {
|
||||
guard ensurePlayerReady() else { return }
|
||||
let clamped = max(0, min(1, fraction))
|
||||
if let player = player {
|
||||
player.currentTime = clamped * player.duration
|
||||
if isPlaying {
|
||||
player.play()
|
||||
}
|
||||
updateProgress()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVAudioPlayerDelegate
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
// Delegate callback may be on background thread - ensure main thread for UI updates
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.stopTimer()
|
||||
self.updateProgress()
|
||||
self.isPlaying = false
|
||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func preparePlayer(for url: URL) {
|
||||
// Prepare player synchronously (only called when playback is requested)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
self.player = player
|
||||
duration = player.duration
|
||||
currentTime = player.currentTime
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
} catch {
|
||||
SecureLogger.error("Voice note playback failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
player = nil
|
||||
duration = 0
|
||||
currentTime = 0
|
||||
progress = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func ensurePlayerReady() -> Bool {
|
||||
if player == nil {
|
||||
preparePlayer(for: url)
|
||||
}
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
||||
try session.setActive(true, options: [])
|
||||
} catch {
|
||||
SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
|
||||
}
|
||||
#endif
|
||||
return player != nil
|
||||
}
|
||||
|
||||
private func startTimer() {
|
||||
if timer != nil { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
self?.updateProgress()
|
||||
}
|
||||
if let timer = timer {
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func updateProgress() {
|
||||
guard let player = player else {
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
progress = 0
|
||||
return
|
||||
}
|
||||
currentTime = player.currentTime
|
||||
duration = player.duration
|
||||
progress = duration > 0 ? currentTime / duration : 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures only one voice note plays at a time.
|
||||
final class VoiceNotePlaybackCoordinator {
|
||||
static let shared = VoiceNotePlaybackCoordinator()
|
||||
|
||||
private weak var activeController: VoiceNotePlaybackController?
|
||||
|
||||
private init() {}
|
||||
|
||||
func activate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
return
|
||||
}
|
||||
activeController?.pause()
|
||||
activeController = controller
|
||||
}
|
||||
|
||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||
if activeController === controller {
|
||||
activeController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||
actor VoiceRecorder {
|
||||
enum RecorderError: Error {
|
||||
case microphoneAccessDenied
|
||||
case recorderInitializationFailed
|
||||
case recordingInProgress
|
||||
}
|
||||
|
||||
static let shared = VoiceRecorder()
|
||||
|
||||
private let paddingInterval: TimeInterval = 0.5
|
||||
private let maxRecordingDuration: TimeInterval = 120
|
||||
static let minRecordingDuration: TimeInterval = 1
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var currentURL: URL?
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
nonisolated
|
||||
func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
#else
|
||||
return true
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Recording Lifecycle
|
||||
|
||||
@discardableResult
|
||||
func startRecording() throws -> URL {
|
||||
if recorder?.isRecording == true {
|
||||
throw RecorderError.recordingInProgress
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
guard session.recordPermission == .granted else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#if targetEnvironment(simulator)
|
||||
// allowBluetoothHFP is not available on iOS Simulator
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||
)
|
||||
#else
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .default,
|
||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||
)
|
||||
#endif
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
#if os(macOS)
|
||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||
throw RecorderError.microphoneAccessDenied
|
||||
}
|
||||
#endif
|
||||
|
||||
let outputURL = try makeOutputURL()
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVSampleRateKey: 16_000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderBitRateKey: 16_000
|
||||
]
|
||||
|
||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||
audioRecorder.isMeteringEnabled = true
|
||||
audioRecorder.prepareToRecord()
|
||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||
|
||||
recorder = audioRecorder
|
||||
currentURL = outputURL
|
||||
return outputURL
|
||||
}
|
||||
|
||||
func stopRecording() async -> URL? {
|
||||
guard let recorder, recorder.isRecording else {
|
||||
return currentURL
|
||||
}
|
||||
|
||||
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() {
|
||||
if let recorder, recorder.isRecording {
|
||||
recorder.stop()
|
||||
}
|
||||
cleanupSession()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeOutputURL() throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date())).m4a"
|
||||
|
||||
let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
#if os(iOS)
|
||||
return try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
#else
|
||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
||||
return base.appendingPathComponent("files", isDirectory: true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func cleanupSession() {
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import BitLogger
|
||||
|
||||
/// Generates and caches downsampled waveforms for audio files so UI rendering is cheap.
|
||||
final class WaveformCache {
|
||||
static let shared = WaveformCache()
|
||||
|
||||
private let queue = DispatchQueue(label: "com.bitchat.waveform-cache", attributes: .concurrent)
|
||||
private var cache: [URL: (waveform: [Float], lastAccess: Date)] = [:]
|
||||
private let maxCacheSize = 20 // Limit cache to prevent unbounded memory growth
|
||||
|
||||
private init() {}
|
||||
|
||||
func cachedWaveform(for url: URL) -> [Float]? {
|
||||
queue.sync {
|
||||
guard let entry = cache[url] else { return nil }
|
||||
return entry.waveform
|
||||
}
|
||||
}
|
||||
|
||||
func waveform(for url: URL, bins: Int = 120, completion: @escaping ([Float]) -> Void) {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Check cache (read-only, no update needed on cache hit for performance)
|
||||
if let entry = self.cache[url] {
|
||||
DispatchQueue.main.async { completion(entry.waveform) }
|
||||
return
|
||||
}
|
||||
|
||||
guard let computed = self.computeWaveform(url: url, bins: bins) else {
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
return
|
||||
}
|
||||
|
||||
self.queue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Evict oldest entry if cache is full
|
||||
if self.cache.count >= self.maxCacheSize {
|
||||
if let oldest = self.cache.min(by: { $0.value.lastAccess < $1.value.lastAccess }) {
|
||||
self.cache.removeValue(forKey: oldest.key)
|
||||
}
|
||||
}
|
||||
|
||||
self.cache[url] = (computed, Date())
|
||||
}
|
||||
DispatchQueue.main.async { completion(computed) }
|
||||
}
|
||||
}
|
||||
|
||||
func purge(url: URL) {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeValue(forKey: url)
|
||||
}
|
||||
}
|
||||
|
||||
func purgeAll() {
|
||||
queue.async(flags: .barrier) { [weak self] in
|
||||
self?.cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||
guard bins > 0 else { return nil }
|
||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||
return autoreleasepool {
|
||||
do {
|
||||
let audioFile = try AVAudioFile(forReading: url)
|
||||
let length = Int(audioFile.length)
|
||||
guard length > 0 else { return nil }
|
||||
|
||||
guard let buffer = AVAudioPCMBuffer(pcmFormat: audioFile.processingFormat, frameCapacity: AVAudioFrameCount(length)) else {
|
||||
return nil
|
||||
}
|
||||
try audioFile.read(into: buffer, frameCount: AVAudioFrameCount(length))
|
||||
guard let channelData = buffer.floatChannelData else { return nil }
|
||||
|
||||
let channelCount = Int(audioFile.processingFormat.channelCount)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
let samplesPerBin = max(1, frameLength / bins)
|
||||
|
||||
var magnitudes: [Float] = Array(repeating: 0, count: bins)
|
||||
for bin in 0..<bins {
|
||||
let start = bin * samplesPerBin
|
||||
let end = min(frameLength, start + samplesPerBin)
|
||||
if start >= end { break }
|
||||
|
||||
var sum: Float = 0
|
||||
var sampleCount = 0
|
||||
for frame in start..<end {
|
||||
var sampleValue: Float = 0
|
||||
for channel in 0..<channelCount {
|
||||
sampleValue += fabsf(channelData[channel][frame])
|
||||
}
|
||||
sum += sampleValue / Float(channelCount)
|
||||
sampleCount += 1
|
||||
}
|
||||
magnitudes[bin] = sampleCount > 0 ? sum / Float(sampleCount) : 0
|
||||
}
|
||||
|
||||
if let maxMagnitude = magnitudes.max(), maxMagnitude > 0 {
|
||||
magnitudes = magnitudes.map { min($0 / maxMagnitude, 1.0) }
|
||||
}
|
||||
return magnitudes
|
||||
} catch {
|
||||
SecureLogger.error("Waveform extraction failed for \(url.lastPathComponent): \(error)", category: .session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
///
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Three-Layer Identity Model
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -201,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||
} catch {
|
||||
// Log error but continue with empty cache
|
||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
||||
cache = IdentityCache()
|
||||
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) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
// Update nickname index
|
||||
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] {
|
||||
// Remove old nickname from index if changed
|
||||
if existingIdentity.claimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
|
||||
}
|
||||
if let previousClaimedNickname,
|
||||
previousClaimedNickname != identity.claimedNickname {
|
||||
self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
|
||||
if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
|
||||
self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,4 +536,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
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] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AppGroupID</key>
|
||||
<string>$(APP_GROUP_ID)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -39,6 +41,10 @@
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
+24568
-23348
File diff suppressed because it is too large
Load Diff
@@ -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 CoreBluetooth
|
||||
import BitFoundation
|
||||
|
||||
/// Represents a peer in the BitChat network with all associated metadata
|
||||
struct BitchatPeer: Equatable {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// CommandsInfo.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
case block
|
||||
case clear
|
||||
case hug
|
||||
case message = "dm"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
return baseCommands
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,16 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
struct ReadReceipt: Codable {
|
||||
let originalMessageID: String
|
||||
let receiptID: String
|
||||
var readerID: String // Who read it
|
||||
var readerID: PeerID // Who read it
|
||||
let readerNickname: String
|
||||
let timestamp: Date
|
||||
|
||||
init(originalMessageID: String, readerID: String, readerNickname: String) {
|
||||
init(originalMessageID: String, readerID: PeerID, readerNickname: String) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = UUID().uuidString
|
||||
self.readerID = readerID
|
||||
@@ -24,7 +25,7 @@ struct ReadReceipt: Codable {
|
||||
}
|
||||
|
||||
// For binary decoding
|
||||
private init(originalMessageID: String, receiptID: String, readerID: String, readerNickname: String, timestamp: Date) {
|
||||
private init(originalMessageID: String, receiptID: String, readerID: PeerID, readerNickname: String, timestamp: Date) {
|
||||
self.originalMessageID = originalMessageID
|
||||
self.receiptID = receiptID
|
||||
self.readerID = readerID
|
||||
@@ -48,7 +49,7 @@ struct ReadReceipt: Codable {
|
||||
data.appendUUID(receiptID)
|
||||
// ReaderID as 8-byte hex string
|
||||
var readerData = Data()
|
||||
var tempID = readerID
|
||||
var tempID = readerID.id
|
||||
while tempID.count >= 2 && readerData.count < 8 {
|
||||
let hexByte = String(tempID.prefix(2))
|
||||
if let byte = UInt8(hexByte, radix: 16) {
|
||||
@@ -78,8 +79,8 @@ struct ReadReceipt: Codable {
|
||||
let receiptID = dataCopy.readUUID(at: &offset) else { return nil }
|
||||
|
||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||
let readerID = readerIDData.hexEncodedString()
|
||||
guard PeerID(str: readerID).isValid else { return nil }
|
||||
let readerID = PeerID(hexData: readerIDData)
|
||||
guard readerID.isValid else { return nil }
|
||||
|
||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||
InputValidator.validateTimestamp(timestamp),
|
||||
|
||||
@@ -8,6 +8,18 @@ struct RequestSyncPacket {
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
let types: SyncTypeFlags?
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
self.data = data
|
||||
self.types = types
|
||||
self.sinceTimestamp = sinceTimestamp
|
||||
self.fragmentIdFilter = fragmentIdFilter
|
||||
}
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
@@ -25,14 +37,27 @@ struct RequestSyncPacket {
|
||||
putTLV(0x02, withUnsafeBytes(of: &mBE) { Data($0) })
|
||||
// data
|
||||
putTLV(0x03, data)
|
||||
if let typesData = types?.toData() {
|
||||
putTLV(0x04, typesData)
|
||||
}
|
||||
if let ts = sinceTimestamp {
|
||||
var tsBE = ts.bigEndian
|
||||
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
|
||||
}
|
||||
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
|
||||
putTLV(0x06, fidData)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
|
||||
var off = 0
|
||||
var p: Int? = nil
|
||||
var m: UInt32? = nil
|
||||
var payload: Data? = nil
|
||||
var types: SyncTypeFlags? = nil
|
||||
var sinceTimestamp: UInt64? = nil
|
||||
var fragmentIdFilter: String? = nil
|
||||
|
||||
while off + 3 <= data.count {
|
||||
let t = Int(data[off]); off += 1
|
||||
@@ -52,12 +77,26 @@ struct RequestSyncPacket {
|
||||
case 0x03:
|
||||
if v.count > maxAcceptBytes { return nil }
|
||||
payload = v
|
||||
case 0x04:
|
||||
if let decoded = SyncTypeFlags.decode(v) {
|
||||
types = decoded
|
||||
}
|
||||
case 0x05:
|
||||
if v.count == 8 {
|
||||
var ts: UInt64 = 0
|
||||
for b in v { ts = (ts << 8) | UInt64(b) }
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd)
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
///
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -165,19 +166,23 @@ final class NoiseCipherState {
|
||||
// MARK: - Sliding Window Replay Protection
|
||||
|
||||
/// Check if nonce is valid for replay protection
|
||||
/// BCH-01-010: Use safe arithmetic to prevent integer overflow
|
||||
private func isValidNonce(_ receivedNonce: UInt64) -> Bool {
|
||||
if receivedNonce + UInt64(Self.REPLAY_WINDOW_SIZE) <= highestReceivedNonce {
|
||||
// Safe overflow check: instead of (receivedNonce + WINDOW_SIZE <= highest)
|
||||
// use (highest >= WINDOW_SIZE && receivedNonce <= highest - WINDOW_SIZE)
|
||||
let windowSize = UInt64(Self.REPLAY_WINDOW_SIZE)
|
||||
if highestReceivedNonce >= windowSize && receivedNonce <= highestReceivedNonce - windowSize {
|
||||
return false // Too old, outside window
|
||||
}
|
||||
|
||||
|
||||
if receivedNonce > highestReceivedNonce {
|
||||
return true // Always accept newer nonces
|
||||
}
|
||||
|
||||
|
||||
let offset = Int(highestReceivedNonce - receivedNonce)
|
||||
let byteIndex = offset / 8
|
||||
let bitIndex = offset % 8
|
||||
|
||||
|
||||
return (replayWindow[byteIndex] & (1 << bitIndex)) == 0 // Not yet seen
|
||||
}
|
||||
|
||||
@@ -222,7 +227,7 @@ final class NoiseCipherState {
|
||||
guard combinedPayload.count >= Self.NONCE_SIZE_BYTES else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Extract 4-byte nonce (big-endian)
|
||||
let nonceData = combinedPayload.prefix(Self.NONCE_SIZE_BYTES)
|
||||
let extractedNonce = nonceData.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) -> UInt64 in
|
||||
@@ -233,18 +238,18 @@ final class NoiseCipherState {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// Extract ciphertext (remaining bytes)
|
||||
let ciphertext = combinedPayload.dropFirst(Self.NONCE_SIZE_BYTES)
|
||||
|
||||
|
||||
return (nonce: extractedNonce, ciphertext: Data(ciphertext))
|
||||
}
|
||||
|
||||
|
||||
/// Convert nonce to 4-byte array (big-endian)
|
||||
private func nonceToBytes(_ nonce: UInt64) -> Data {
|
||||
var bytes = Data(count: Self.NONCE_SIZE_BYTES)
|
||||
withUnsafeBytes(of: nonce.bigEndian) { ptr in
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
// Copy only the last 4 bytes from the 8-byte UInt64
|
||||
let sourceBytes = ptr.bindMemory(to: UInt8.self)
|
||||
bytes.replaceSubrange(0..<Self.NONCE_SIZE_BYTES, with: sourceBytes.suffix(Self.NONCE_SIZE_BYTES))
|
||||
}
|
||||
@@ -273,7 +278,7 @@ final class NoiseCipherState {
|
||||
let sealedBox = try ChaChaPoly.seal(plaintext, using: key, nonce: ChaChaPoly.Nonce(data: nonceData), authenticating: associatedData)
|
||||
// increment local nonce
|
||||
nonce += 1
|
||||
|
||||
|
||||
// Create combined payload: <nonce><ciphertext>
|
||||
let combinedPayload: Data
|
||||
if (useExtractedNonce) {
|
||||
@@ -287,7 +292,7 @@ final class NoiseCipherState {
|
||||
if currentNonce > Self.HIGH_NONCE_WARNING_THRESHOLD {
|
||||
SecureLogger.warning("High nonce value detected: \(currentNonce) - consider rekeying", category: .encryption)
|
||||
}
|
||||
|
||||
|
||||
return combinedPayload
|
||||
}
|
||||
|
||||
@@ -316,7 +321,7 @@ final class NoiseCipherState {
|
||||
SecureLogger.debug("Replay attack detected: nonce \(extractedNonce) rejected")
|
||||
throw NoiseError.replayDetected
|
||||
}
|
||||
|
||||
|
||||
// Split ciphertext and tag
|
||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||
tag = actualCiphertext.suffix(16)
|
||||
@@ -347,16 +352,20 @@ final class NoiseCipherState {
|
||||
|
||||
do {
|
||||
let plaintext = try ChaChaPoly.open(sealedBox, using: key, authenticating: associatedData)
|
||||
|
||||
|
||||
// BCH-01-010: Atomic nonce state update
|
||||
// Both replay window marking and nonce increment must complete together
|
||||
// to prevent state desynchronization. We perform both after successful
|
||||
// decryption only, ensuring state consistency on any failure path.
|
||||
if useExtractedNonce {
|
||||
// Mark nonce as seen after successful decryption
|
||||
markNonceAsSeen(decryptionNonce)
|
||||
}
|
||||
nonce += 1
|
||||
|
||||
return plaintext
|
||||
} catch {
|
||||
// Decryption failed - nonce state remains unchanged (atomic rollback)
|
||||
SecureLogger.debug("Decrypt failed: \(error) for nonce \(decryptionNonce)")
|
||||
// Log authentication failures with nonce info
|
||||
SecureLogger.error("Decryption failed at nonce \(decryptionNonce)", category: .encryption)
|
||||
throw error
|
||||
}
|
||||
@@ -376,6 +385,16 @@ final class NoiseCipherState {
|
||||
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
|
||||
@@ -451,17 +470,40 @@ final class NoiseSymmetricState {
|
||||
}
|
||||
}
|
||||
|
||||
func split() -> (NoiseCipherState, NoiseCipherState) {
|
||||
func split(useExtractedNonce: Bool) -> (NoiseCipherState, NoiseCipherState) {
|
||||
let output = hkdf(chainingKey: chainingKey, inputKeyMaterial: Data(), numOutputs: 2)
|
||||
let tempKey1 = SymmetricKey(data: output[0])
|
||||
let tempKey2 = SymmetricKey(data: output[1])
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: true)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: true)
|
||||
|
||||
|
||||
let c1 = NoiseCipherState(key: tempKey1, useExtractedNonce: useExtractedNonce)
|
||||
let c2 = NoiseCipherState(key: tempKey2, useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// BCH-01-010: Clear symmetric state after split per Noise spec
|
||||
// The chaining key and hash should not be retained after handshake completes
|
||||
clearSensitiveData()
|
||||
|
||||
return (c1, c2)
|
||||
}
|
||||
|
||||
|
||||
/// BCH-01-010: Securely clear sensitive cryptographic state
|
||||
/// Called after split() to clear chaining key and hash per Noise spec
|
||||
func clearSensitiveData() {
|
||||
// Clear chaining key by overwriting with zeros
|
||||
let chainingKeyCount = chainingKey.count
|
||||
chainingKey = Data(repeating: 0, count: chainingKeyCount)
|
||||
|
||||
// Clear hash by overwriting with zeros
|
||||
let hashCount = hash.count
|
||||
hash = Data(repeating: 0, count: hashCount)
|
||||
|
||||
// Clear the internal cipher state
|
||||
cipherState.clearSensitiveData()
|
||||
}
|
||||
|
||||
deinit {
|
||||
clearSensitiveData()
|
||||
}
|
||||
|
||||
// HKDF implementation
|
||||
private func hkdf(chainingKey: Data, inputKeyMaterial: Data, numOutputs: Int) -> [Data] {
|
||||
let tempKey = HMAC<SHA256>.authenticationCode(for: inputKeyMaterial, using: SymmetricKey(data: chainingKey))
|
||||
@@ -507,16 +549,24 @@ final class NoiseHandshakeState {
|
||||
private var messagePatterns: [[NoiseMessagePattern]] = []
|
||||
private var currentPattern = 0
|
||||
|
||||
// Test support: predetermined ephemeral keys for test vectors
|
||||
private var predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey?
|
||||
private var prologueData: Data
|
||||
|
||||
init(
|
||||
role: NoiseRole,
|
||||
pattern: NoisePattern,
|
||||
keychain: KeychainManagerProtocol,
|
||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey? = nil,
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil
|
||||
remoteStaticKey: Curve25519.KeyAgreement.PublicKey? = nil,
|
||||
prologue: Data = Data(),
|
||||
predeterminedEphemeralKey: Curve25519.KeyAgreement.PrivateKey? = nil
|
||||
) {
|
||||
self.role = role
|
||||
self.pattern = pattern
|
||||
self.keychain = keychain
|
||||
self.prologueData = prologue
|
||||
self.predeterminedEphemeralKey = predeterminedEphemeralKey
|
||||
|
||||
// Initialize static keys
|
||||
if let localKey = localStaticKey {
|
||||
@@ -537,8 +587,8 @@ final class NoiseHandshakeState {
|
||||
}
|
||||
|
||||
private func mixPreMessageKeys() {
|
||||
// Mix prologue (empty for XX pattern normally)
|
||||
symmetricState.mixHash(Data()) // Empty prologue for XX pattern
|
||||
// Mix prologue
|
||||
symmetricState.mixHash(self.prologueData)
|
||||
// For XX pattern, no pre-message keys
|
||||
// For IK/NK patterns, we'd mix the responder's static key here
|
||||
switch pattern {
|
||||
@@ -546,8 +596,9 @@ final class NoiseHandshakeState {
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
_ = symmetricState.getHandshakeHash()
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
symmetricState.mixHash(localStatic.rawRepresentation)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,15 +607,20 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var messageBuffer = Data()
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
for pattern in patterns {
|
||||
switch pattern {
|
||||
case .e:
|
||||
// Generate ephemeral key
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
// Generate ephemeral key (or use predetermined key for tests)
|
||||
if let predetermined = predeterminedEphemeralKey {
|
||||
localEphemeralPrivate = predetermined
|
||||
predeterminedEphemeralKey = nil
|
||||
} else {
|
||||
localEphemeralPrivate = Curve25519.KeyAgreement.PrivateKey()
|
||||
}
|
||||
localEphemeralPublic = localEphemeralPrivate!.publicKey
|
||||
messageBuffer.append(localEphemeralPublic!.rawRepresentation)
|
||||
symmetricState.mixHash(localEphemeralPublic!.rawRepresentation)
|
||||
@@ -597,14 +653,20 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localStatic = localStaticPrivate,
|
||||
let remoteEphemeral = remoteEphemeralPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .se:
|
||||
@@ -615,14 +677,20 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
} else {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
let remoteStatic = remoteStaticPublic else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
}
|
||||
|
||||
case .ss:
|
||||
@@ -652,7 +720,7 @@ final class NoiseHandshakeState {
|
||||
guard currentPattern < messagePatterns.count else {
|
||||
throw NoiseError.handshakeComplete
|
||||
}
|
||||
|
||||
|
||||
var buffer = message
|
||||
let patterns = messagePatterns[currentPattern]
|
||||
|
||||
@@ -711,8 +779,11 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localEphemeral.sharedSecretFromKeyAgreement(with: remoteEphemeral)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .es:
|
||||
if role == .initiator {
|
||||
guard let localEphemeral = localEphemeralPrivate,
|
||||
@@ -765,9 +836,12 @@ final class NoiseHandshakeState {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let shared = try localStatic.sharedSecretFromKeyAgreement(with: remoteStatic)
|
||||
symmetricState.mixKey(shared.withUnsafeBytes { Data($0) })
|
||||
|
||||
default:
|
||||
var sharedData = shared.withUnsafeBytes { Data($0) }
|
||||
symmetricState.mixKey(sharedData)
|
||||
// Clear sensitive shared secret
|
||||
keychain.secureClear(&sharedData)
|
||||
|
||||
case .e, .s:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -776,16 +850,20 @@ final class NoiseHandshakeState {
|
||||
return currentPattern >= messagePatterns.count
|
||||
}
|
||||
|
||||
func getTransportCiphers() throws -> (send: NoiseCipherState, receive: NoiseCipherState) {
|
||||
func getTransportCiphers(useExtractedNonce: Bool) throws -> (send: NoiseCipherState, receive: NoiseCipherState, handshakeHash: Data) {
|
||||
guard isHandshakeComplete() else {
|
||||
throw NoiseError.handshakeNotComplete
|
||||
}
|
||||
|
||||
let (c1, c2) = symmetricState.split()
|
||||
|
||||
|
||||
// BCH-01-010: Capture handshake hash BEFORE split() clears symmetric state
|
||||
let finalHandshakeHash = symmetricState.getHandshakeHash()
|
||||
|
||||
let (c1, c2) = symmetricState.split(useExtractedNonce: useExtractedNonce)
|
||||
|
||||
// Initiator uses c1 for sending, c2 for receiving
|
||||
// Responder uses c2 for sending, c1 for receiving
|
||||
return role == .initiator ? (c1, c2) : (c2, c1)
|
||||
let ciphers = role == .initiator ? (c1, c2) : (c2, c1)
|
||||
return (send: ciphers.0, receive: ciphers.1, handshakeHash: finalHandshakeHash)
|
||||
}
|
||||
|
||||
func getRemoteStaticPublicKey() -> Curve25519.KeyAgreement.PublicKey? {
|
||||
@@ -795,6 +873,20 @@ final class NoiseHandshakeState {
|
||||
func getHandshakeHash() -> Data {
|
||||
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
|
||||
@@ -846,22 +938,47 @@ enum NoiseError: Error {
|
||||
case nonceExceeded
|
||||
}
|
||||
|
||||
// MARK: - Constant-Time Operations
|
||||
|
||||
/// BCH-01-010: Constant-time comparison to prevent timing side-channel attacks
|
||||
/// This function compares two Data objects in constant time, preventing
|
||||
/// information leakage via timing analysis.
|
||||
private func constantTimeCompare(_ a: Data, _ b: Data) -> Bool {
|
||||
guard a.count == b.count else { return false }
|
||||
|
||||
var result: UInt8 = 0
|
||||
for i in 0..<a.count {
|
||||
result |= a[a.startIndex.advanced(by: i)] ^ b[b.startIndex.advanced(by: i)]
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
/// BCH-01-010: Constant-time check if all bytes are zero
|
||||
private func constantTimeIsZero(_ data: Data) -> Bool {
|
||||
var result: UInt8 = 0
|
||||
for byte in data {
|
||||
result |= byte
|
||||
}
|
||||
return result == 0
|
||||
}
|
||||
|
||||
// MARK: - Key Validation
|
||||
|
||||
extension NoiseHandshakeState {
|
||||
/// Validate a Curve25519 public key
|
||||
/// Checks for weak/invalid keys that could compromise security
|
||||
/// BCH-01-010: Uses constant-time operations to prevent timing side-channels
|
||||
static func validatePublicKey(_ keyData: Data) throws -> Curve25519.KeyAgreement.PublicKey {
|
||||
// Check key length
|
||||
guard keyData.count == 32 else {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
// Check for all-zero key (point at infinity)
|
||||
if keyData.allSatisfy({ $0 == 0 }) {
|
||||
|
||||
// BCH-01-010: Constant-time check for all-zero key (point at infinity)
|
||||
if constantTimeIsZero(keyData) {
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Check for low-order points that could enable small subgroup attacks
|
||||
// These are the known bad points for Curve25519
|
||||
let lowOrderPoints: [Data] = [
|
||||
@@ -882,13 +999,21 @@ extension NoiseHandshakeState {
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]) // Another bad point
|
||||
]
|
||||
|
||||
// Check against known bad points
|
||||
if lowOrderPoints.contains(keyData) {
|
||||
|
||||
// BCH-01-010: Constant-time check against known bad points
|
||||
// We check all points and accumulate matches to avoid early exit timing leaks
|
||||
var foundBadPoint = false
|
||||
for badPoint in lowOrderPoints {
|
||||
if constantTimeCompare(keyData, badPoint) {
|
||||
foundBadPoint = true
|
||||
}
|
||||
}
|
||||
|
||||
if foundBadPoint {
|
||||
SecureLogger.warning("Low-order point detected", category: .security)
|
||||
throw NoiseError.invalidPublicKey
|
||||
}
|
||||
|
||||
|
||||
// Try to create the key - CryptoKit will validate curve points internally
|
||||
do {
|
||||
let publicKey = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: keyData)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
final class NoiseRateLimiter {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
|
||||
class NoiseSession {
|
||||
let peerID: PeerID
|
||||
@@ -102,23 +103,23 @@ class NoiseSession {
|
||||
|
||||
// Check if handshake is complete
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
|
||||
|
||||
return nil
|
||||
} else {
|
||||
// Generate response
|
||||
@@ -128,20 +129,20 @@ class NoiseSession {
|
||||
|
||||
// Check if handshake is complete after writing
|
||||
if handshake.isHandshakeComplete() {
|
||||
// Get transport ciphers
|
||||
let (send, receive) = try handshake.getTransportCiphers()
|
||||
// Get transport ciphers and handshake hash (hash captured before split clears state)
|
||||
let (send, receive, hash) = try handshake.getTransportCiphers(useExtractedNonce: true)
|
||||
sendCipher = send
|
||||
receiveCipher = receive
|
||||
|
||||
|
||||
// Store remote static key
|
||||
remoteStaticPublicKey = handshake.getRemoteStaticPublicKey()
|
||||
|
||||
|
||||
// Store handshake hash for channel binding
|
||||
handshakeHash = handshake.getHandshakeHash()
|
||||
|
||||
handshakeHash = hash
|
||||
|
||||
state = .established
|
||||
handshakeState = nil // Clear handshake state
|
||||
|
||||
|
||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
final class NoiseSessionManager {
|
||||
private var sessions: [PeerID: NoiseSession] = [:]
|
||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||
|
||||
// Callbacks
|
||||
@@ -23,7 +25,27 @@ final class NoiseSessionManager {
|
||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||
self.localStaticKey = localStaticKey
|
||||
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
|
||||
|
||||
@@ -66,12 +88,7 @@ final class NoiseSessionManager {
|
||||
}
|
||||
|
||||
// Create new initiator session
|
||||
let session = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .initiator,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
let session = sessionFactory(peerID, .initiator)
|
||||
sessions[peerID] = session
|
||||
|
||||
do {
|
||||
@@ -117,12 +134,7 @@ final class NoiseSessionManager {
|
||||
// Get or create session
|
||||
let session: NoiseSession
|
||||
if shouldCreateNew {
|
||||
let newSession = SecureNoiseSession(
|
||||
peerID: peerID,
|
||||
role: .responder,
|
||||
keychain: keychain,
|
||||
localStaticKey: localStaticKey
|
||||
)
|
||||
let newSession = sessionFactory(peerID, .responder)
|
||||
sessions[peerID] = newSession
|
||||
session = newSession
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,7 @@ import Foundation
|
||||
|
||||
final class SecureNoiseSession: NoiseSession {
|
||||
private(set) var messageCount: UInt64 = 0
|
||||
private let sessionStartTime = Date()
|
||||
private var sessionStartTime = Date()
|
||||
private(set) var lastActivityTime = Date()
|
||||
|
||||
override func encrypt(_ plaintext: Data) throws -> Data {
|
||||
@@ -77,5 +77,9 @@ final class SecureNoiseSession: NoiseSession {
|
||||
func setMessageCountForTesting(_ count: UInt64) {
|
||||
messageCount = count
|
||||
}
|
||||
|
||||
func setSessionStartTimeForTesting(_ date: Date) {
|
||||
sessionStartTime = date
|
||||
}
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,161 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Tor
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// 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
|
||||
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 lat: 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()
|
||||
|
||||
private(set) var entries: [Entry] = []
|
||||
private let cacheFileName = "georelays_cache.csv"
|
||||
private let lastFetchKey = "georelay.lastFetchAt"
|
||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
|
||||
private let dependencies: GeoRelayDirectoryDependencies
|
||||
private let cleanupState: CleanupState
|
||||
|
||||
private var retryAttempt: Int = 0
|
||||
private var isFetching: Bool = false
|
||||
|
||||
private init() {
|
||||
// Load cached or bundled data synchronously
|
||||
self.entries = self.loadLocalEntries()
|
||||
// Fire-and-forget remote refresh if stale
|
||||
prefetchIfNeeded()
|
||||
self.dependencies = .live()
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
internal init(dependencies: GeoRelayDirectoryDependencies) {
|
||||
self.dependencies = dependencies
|
||||
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
|
||||
entries = loadLocalEntries()
|
||||
if dependencies.autoStart {
|
||||
registerObservers()
|
||||
startRefreshTimer()
|
||||
prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
||||
@@ -62,46 +195,147 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
func prefetchIfNeeded() {
|
||||
let now = Date()
|
||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
||||
func prefetchIfNeeded(force: Bool = false) {
|
||||
guard !isFetching else { return }
|
||||
|
||||
let now = dependencies.now()
|
||||
let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
||||
|
||||
if !force {
|
||||
guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
|
||||
} else if last != .distantPast,
|
||||
now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
|
||||
// Skip forced fetches if we just refreshed moments ago.
|
||||
return
|
||||
}
|
||||
|
||||
cancelRetry()
|
||||
fetchRemote()
|
||||
}
|
||||
|
||||
private func fetchRemote() {
|
||||
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
|
||||
// Ensure Tor readiness before fetching (fail-closed by default)
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
if !ready {
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; skipping remote fetch (fail-closed)", category: .session)
|
||||
return
|
||||
guard !isFetching else { return }
|
||||
isFetching = true
|
||||
|
||||
let request = URLRequest(
|
||||
url: dependencies.remoteURL,
|
||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
||||
timeoutInterval: 15
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
case .success(let parsed, let csv):
|
||||
self.handleFetchSuccess(entries: parsed, csv: csv)
|
||||
case .torNotReady:
|
||||
self.handleFetchFailure(.torNotReady)
|
||||
case .invalidData:
|
||||
self.handleFetchFailure(.invalidData)
|
||||
case .network(let description):
|
||||
self.handleFetchFailure(.network(description))
|
||||
}
|
||||
let task = TorURLSession.shared.session.dataTask(with: req) { [weak self] data, _, error in
|
||||
guard let self = self else { return }
|
||||
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
|
||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
||||
if !parsed.isEmpty {
|
||||
Task { @MainActor in
|
||||
self.entries = parsed
|
||||
self.persistCache(text)
|
||||
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed; keeping local entries", category: .session)
|
||||
}
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
||||
isFetching = false
|
||||
retryAttempt = 0
|
||||
cancelRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
||||
switch reason {
|
||||
case .torNotReady:
|
||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
||||
case .invalidData:
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
||||
case .network(let errorDescription):
|
||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
|
||||
}
|
||||
isFetching = false
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func scheduleRetry() {
|
||||
retryAttempt = min(retryAttempt + 1, 10)
|
||||
let base = dependencies.retryInitialSeconds
|
||||
let maxDelay = dependencies.retryMaxSeconds
|
||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
||||
let calculated = base * multiplier
|
||||
let delay = min(maxDelay, max(base, calculated))
|
||||
|
||||
cancelRetry()
|
||||
cleanupState.retryTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.dependencies.retrySleep(delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func cancelRetry() {
|
||||
cleanupState.retryTask?.cancel()
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
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 {
|
||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
||||
}
|
||||
@@ -110,30 +344,31 @@ final class GeoRelayDirectory {
|
||||
// MARK: - Loading
|
||||
private func loadLocalEntries() -> [Entry] {
|
||||
// Prefer cached file if present
|
||||
if let cache = self.cacheURL(),
|
||||
let data = try? Data(contentsOf: cache),
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
let bundleCandidates = [
|
||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
||||
].compactMap { $0 }
|
||||
let bundleCandidates = dependencies.bundledCSVURLs()
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
|
||||
if let data = dependencies.readData(url),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
@@ -141,33 +376,67 @@ final class GeoRelayDirectory {
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||
// Skip header if present
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { 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 }
|
||||
var host = parts[0]
|
||||
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 host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
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
|
||||
private func registerObservers() {
|
||||
let center = dependencies.notificationCenter
|
||||
|
||||
let torReady = center.addObserver(
|
||||
forName: .TorDidBecomeReady,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded(force: true)
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(torReady)
|
||||
|
||||
if let activeNotificationName = dependencies.activeNotificationName {
|
||||
let didBecomeActive = center.addObserver(
|
||||
forName: activeNotificationName,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.observers.append(didBecomeActive)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRefreshTimer() {
|
||||
cleanupState.refreshTimer?.invalidate()
|
||||
let interval = dependencies.refreshCheckInterval
|
||||
guard interval > 0 else { return }
|
||||
|
||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor in
|
||||
self.prefetchIfNeeded()
|
||||
}
|
||||
}
|
||||
cleanupState.refreshTimer = timer
|
||||
RunLoop.main.add(timer, forMode: .common)
|
||||
}
|
||||
|
||||
var debugRetryAttempt: Int { retryAttempt }
|
||||
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
|
||||
var debugObserverCount: Int { cleanupState.observers.count }
|
||||
}
|
||||
|
||||
// MARK: - Distance
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
protocol KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString?)
|
||||
func load(key: String, service: String) -> Data?
|
||||
func delete(key: String, service: String)
|
||||
}
|
||||
|
||||
/// Keychain helper for secure storage
|
||||
struct KeychainHelper: KeychainHelperProtocol {
|
||||
func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
func load(key: String, service: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
func delete(key: String, service: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - BitChat-over-Nostr Adapter
|
||||
|
||||
struct NostrEmbeddedBitChat {
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs.
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostr(content: String, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
// TLV-encode the private message
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
@@ -14,12 +15,12 @@ struct NostrEmbeddedBitChat {
|
||||
payload.append(tlv)
|
||||
|
||||
// Determine 8-byte recipient ID to embed
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -31,18 +32,18 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` base64url-encoded BitChat packet carrying a delivery/read ack for Nostr DMs.
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostr(type: NoisePayloadType, messageID: String, recipientPeerID: PeerID, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let recipientIDHex: String = normalizeRecipientPeerID(recipientPeerID)
|
||||
let recipientID = normalizeRecipientPeerID(recipientPeerID)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: Data(hexString: recipientIDHex),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: recipientID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
@@ -54,7 +55,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
@@ -62,7 +63,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -75,7 +76,7 @@ struct NostrEmbeddedBitChat {
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: PeerID) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
@@ -84,7 +85,7 @@ struct NostrEmbeddedBitChat {
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
senderID: Data(hexString: senderPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -96,11 +97,11 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: PeerID) -> PeerID {
|
||||
if let maybeData = Data(hexString: recipientPeerID.id) {
|
||||
if maybeData.count == 32 {
|
||||
// Treat as Noise static public key; derive peerID from fingerprint
|
||||
return PeerID(publicKey: maybeData).id
|
||||
return PeerID(publicKey: maybeData)
|
||||
} else if maybeData.count == 8 {
|
||||
// Already an 8-byte peer ID
|
||||
return recipientPeerID
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
@@ -8,10 +9,13 @@ final class NostrIdentityBridge {
|
||||
private let deviceSeedKey = "nostr-device-seed"
|
||||
// In-memory cache to avoid transient keychain access issues
|
||||
private var deviceSeedCache: Data?
|
||||
|
||||
private let keychain: KeychainHelperProtocol
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
// Cache derived identities to avoid repeated crypto during view rendering
|
||||
private var derivedIdentityCache: [String: NostrIdentity] = [:]
|
||||
private let cacheLock = NSLock()
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
@@ -106,6 +110,14 @@ final class NostrIdentityBridge {
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
// Check cache first to avoid repeated crypto + keychain I/O during view rendering
|
||||
cacheLock.lock()
|
||||
if let cached = derivedIdentityCache[geohash] {
|
||||
cacheLock.unlock()
|
||||
return cached
|
||||
}
|
||||
cacheLock.unlock()
|
||||
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
@@ -125,11 +137,22 @@ final class NostrIdentityBridge {
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
let fallback = (seed + msg).sha256Hash()
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
let identity = try NostrIdentity(privateKeyData: fallback)
|
||||
|
||||
// Cache the result
|
||||
cacheLock.lock()
|
||||
derivedIdentityCache[geohash] = identity
|
||||
cacheLock.unlock()
|
||||
|
||||
return identity
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct NostrProtocol {
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -108,7 +109,7 @@ struct NostrProtocol {
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
if teleported {
|
||||
@@ -125,6 +126,24 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [["g", geohash]]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
@@ -133,7 +152,7 @@ struct NostrProtocol {
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty {
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
@@ -284,7 +303,7 @@ struct NostrProtocol {
|
||||
combined.append(nonce24)
|
||||
combined.append(sealed.ciphertext)
|
||||
combined.append(sealed.tag)
|
||||
return "v2:" + base64URLEncode(combined)
|
||||
return "v2:" + Base64URLCoding.encode(combined)
|
||||
}
|
||||
|
||||
private static func decrypt(
|
||||
@@ -295,7 +314,7 @@ struct NostrProtocol {
|
||||
// Expect NIP-44 v2 format
|
||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||
let encoded = String(ciphertext.dropFirst(3))
|
||||
guard let data = base64URLDecode(encoded),
|
||||
guard let data = Base64URLCoding.decode(encoded),
|
||||
data.count > (24 + 16),
|
||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||
throw NostrError.invalidCiphertext
|
||||
@@ -509,6 +528,26 @@ struct NostrEvent: Codable {
|
||||
signed.sig = signatureHex
|
||||
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) {
|
||||
let serialized = [
|
||||
@@ -541,24 +580,9 @@ enum NostrError: Error {
|
||||
case encryptionFailed
|
||||
}
|
||||
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||
|
||||
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 {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
|
||||
@@ -4,6 +4,100 @@ import Network
|
||||
import Combine
|
||||
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
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
@@ -27,31 +121,40 @@ final class NostrRelayManager: ObservableObject {
|
||||
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 = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.primal.net",
|
||||
"wss://offchain.pub",
|
||||
"wss://nostr21.com"
|
||||
"wss://offchain.pub"
|
||||
// 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 isConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
private var hasMutualFavorites: 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 pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
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 pendingTorConnectionURLs = Set<String>()
|
||||
private var awaitingTorForConnections = false
|
||||
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
|
||||
private struct EOSETracker {
|
||||
var pendingRelays: Set<String>
|
||||
@@ -59,6 +162,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
var timer: Timer?
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
// Pending sends held only for relays that are not yet connected.
|
||||
@@ -69,9 +173,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
private var messageQueue: [PendingSend] = []
|
||||
private let messageQueueLock = NSLock()
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var networkService: NetworkActivationService { NetworkActivationService.shared }
|
||||
private var shouldUseTor: Bool { networkService.userTorEnabled }
|
||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||
|
||||
// Exponential backoff configuration
|
||||
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
|
||||
@@ -79,18 +181,17 @@ final class NostrRelayManager: ObservableObject {
|
||||
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
|
||||
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
|
||||
|
||||
// Reconnection timer
|
||||
private var reconnectionTimer: Timer?
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
init() {
|
||||
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
hasLocationPermission = dependencies.hasLocationPermission()
|
||||
applyDefaultRelayPolicy(force: true)
|
||||
// Deterministic JSON shape for outbound requests
|
||||
self.encoder.outputFormatting = .sortedKeys
|
||||
FavoritesPersistenceService.shared.$mutualFavorites
|
||||
dependencies.mutualFavoritesPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] favorites in
|
||||
guard let self = self else { return }
|
||||
@@ -98,7 +199,34 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.applyDefaultRelayPolicy()
|
||||
}
|
||||
.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)
|
||||
.sink { [weak self] state in
|
||||
guard let self = self else { return }
|
||||
@@ -113,28 +241,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor {
|
||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
||||
Task.detached {
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run {
|
||||
if !ready {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
return
|
||||
}
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
||||
for relay in self.relays {
|
||||
self.connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
||||
for relay in self.relays {
|
||||
connectToRelay(relay.url)
|
||||
}
|
||||
}
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
connectToRelays(relays.map(\.url), shouldLog: true)
|
||||
}
|
||||
|
||||
/// Disconnect from all relays
|
||||
@@ -147,44 +255,40 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
for (_, tracker) in eoseTrackers {
|
||||
tracker.timer?.invalidate()
|
||||
}
|
||||
eoseTrackers.removeAll()
|
||||
pendingTorConnectionURLs.removeAll()
|
||||
awaitingTorForConnections = false
|
||||
updateConnectionStatus()
|
||||
}
|
||||
|
||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||
func ensureConnections(to relayUrls: [String]) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
let targets = allowedRelayList(from: relayUrls)
|
||||
guard !targets.isEmpty else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
||||
Task.detached { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
|
||||
}
|
||||
return
|
||||
}
|
||||
var existing = Set(relays.map { $0.url })
|
||||
for url in targets where !existing.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
for url in targets where connections[url] == nil {
|
||||
connectToRelay(url)
|
||||
}
|
||||
connectToRelays(targets)
|
||||
}
|
||||
|
||||
/// Send an event to specified relays (or all if none specified)
|
||||
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// 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 }
|
||||
let ready = await TorManager.shared.awaitReady()
|
||||
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
|
||||
if ready { self.sendEvent(event, to: relayUrls) }
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -196,7 +300,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempt immediate send to relays with active connections; queue the rest
|
||||
var stillPending = Set<String>()
|
||||
for relayUrl in targetRelays {
|
||||
if let connection = connections[relayUrl] {
|
||||
if let connection = connectedConnection(for: relayUrl) {
|
||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||
} else {
|
||||
stillPending.insert(relayUrl)
|
||||
@@ -218,7 +322,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Flush only for a specific relay
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
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)
|
||||
item.pendingRelays.remove(target)
|
||||
if item.pendingRelays.isEmpty {
|
||||
@@ -233,7 +337,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
for i in (0..<messageQueue.count).reversed() {
|
||||
var item = messageQueue[i]
|
||||
for url in item.pendingRelays {
|
||||
if let conn = connections[url] {
|
||||
if let conn = connectedConnection(for: url) {
|
||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||
item.pendingRelays.remove(url)
|
||||
}
|
||||
@@ -246,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.
|
||||
func subscribe(
|
||||
@@ -256,28 +368,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
onEOSE: (() -> Void)? = nil
|
||||
) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
||||
let now = Date()
|
||||
if messageHandlers[id] != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||
let now = dependencies.now()
|
||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||
return
|
||||
}
|
||||
subscribeCoalesce[id] = now
|
||||
messageHandlers[id] = handler
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
@@ -293,14 +390,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
||||
let urls = allowedRelayList(from: candidateUrls)
|
||||
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||
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
|
||||
let existingSet = Set(relays.map { $0.url })
|
||||
var existingSet = Set(relays.map { $0.url })
|
||||
for url in urls where !existingSet.contains(url) {
|
||||
relays.append(Relay(url: url))
|
||||
existingSet.insert(url)
|
||||
}
|
||||
for url in candidateUrls {
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
@@ -309,20 +412,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else if shouldWaitForTorBeforeConnecting {
|
||||
pendingEOSECallbacks[id] = onEOSE
|
||||
} else {
|
||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
||||
// Fallback timeout to avoid hanging if a relay never sends EOSE
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self = self else { return }
|
||||
if let t = self.eoseTrackers[id] {
|
||||
t.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
onEOSE()
|
||||
}
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||
}
|
||||
}
|
||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||
@@ -349,7 +442,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays.append(Relay(url: url))
|
||||
existing.insert(url)
|
||||
}
|
||||
if networkService.activationAllowed {
|
||||
if dependencies.activationAllowed() {
|
||||
ensureConnections(to: Self.defaultRelays)
|
||||
}
|
||||
} else {
|
||||
@@ -359,6 +452,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
messageQueueLock.lock()
|
||||
for index in (0..<messageQueue.count).reversed() {
|
||||
@@ -379,7 +473,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||
var seen = Set<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 seen.insert(url).inserted {
|
||||
result.append(url)
|
||||
@@ -393,6 +488,13 @@ final class NostrRelayManager: ObservableObject {
|
||||
messageHandlers.removeValue(forKey: id)
|
||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||
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 message = try? encoder.encode(req)
|
||||
@@ -403,27 +505,120 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Send unsubscribe to all relays
|
||||
for (relayUrl, connection) in connections {
|
||||
if subscriptions[relayUrl]?.contains(id) == true {
|
||||
subscriptions[relayUrl]?.remove(id)
|
||||
connection.send(.string(messageString)) { _ in
|
||||
Task { @MainActor in
|
||||
self.subscriptions[relayUrl]?.remove(id)
|
||||
}
|
||||
// Local state is cleared before sending so callers can re-subscribe immediately.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Global network policy gate
|
||||
guard networkService.activationAllowed else { return }
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
guard let url = URL(string: urlString) else {
|
||||
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() {
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -438,19 +633,12 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Attempting to connect to Nostr relay via the proxied session
|
||||
|
||||
// If Tor is enforced but not ready, delay connection until it is.
|
||||
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady {
|
||||
Task.detached { [weak self] in
|
||||
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) }
|
||||
}
|
||||
}
|
||||
if shouldWaitForTorBeforeConnecting {
|
||||
queueConnectionsUntilTorReady([urlString])
|
||||
return
|
||||
}
|
||||
|
||||
let session = TorURLSession.shared.session
|
||||
let session = dependencies.makeSession()
|
||||
let task = session.webSocketTask(with: url)
|
||||
|
||||
connections[urlString] = task
|
||||
@@ -483,6 +671,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
for (id, messageString) in map {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
startPendingEOSETrackingIfNeeded(id: id)
|
||||
connection.send(.string(messageString)) { error in
|
||||
if let error = error {
|
||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||
@@ -498,7 +687,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
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
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -508,7 +697,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl)
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,9 +751,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
} else {
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
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 {
|
||||
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:
|
||||
@@ -572,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)
|
||||
|
||||
do {
|
||||
@@ -604,11 +793,11 @@ final class NostrRelayManager: ObservableObject {
|
||||
relays[index].isConnected = isConnected
|
||||
relays[index].lastError = error
|
||||
if isConnected {
|
||||
relays[index].lastConnectedAt = Date()
|
||||
relays[index].lastConnectedAt = dependencies.now()
|
||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||
relays[index].nextReconnectTime = nil
|
||||
} else {
|
||||
relays[index].lastDisconnectedAt = Date()
|
||||
relays[index].lastDisconnectedAt = dependencies.now()
|
||||
}
|
||||
}
|
||||
updateConnectionStatus()
|
||||
@@ -624,7 +813,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||
// If networking is disallowed, do not schedule reconnection
|
||||
if !networkService.activationAllowed {
|
||||
if !dependencies.activationAllowed() {
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
||||
@@ -669,19 +858,21 @@ final class NostrRelayManager: ObservableObject {
|
||||
maxBackoffInterval
|
||||
)
|
||||
|
||||
let nextReconnectTime = Date().addingTimeInterval(backoffInterval)
|
||||
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||
relays[index].nextReconnectTime = nextReconnectTime
|
||||
|
||||
|
||||
// Schedule reconnection with exponential backoff
|
||||
let gen = connectionGeneration
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
dependencies.scheduleAfter(backoffInterval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
// Ignore stale scheduled reconnects from a previous generation
|
||||
guard gen == self.connectionGeneration else { return }
|
||||
// Check if we should still reconnect (relay might have been removed)
|
||||
if self.relays.contains(where: { $0.url == relayUrl }) {
|
||||
self.connectToRelay(relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,20 +881,22 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
/// Manually retry connection to a specific relay
|
||||
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
|
||||
relays[index].reconnectAttempts = 0
|
||||
relays[index].nextReconnectTime = nil
|
||||
relays[index].lastError = nil
|
||||
|
||||
// Disconnect if connected
|
||||
if let connection = connections[relayUrl] {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
// Attempt immediate reconnection
|
||||
connectToRelay(relayUrl)
|
||||
connectToRelay(normalizedRelayUrl)
|
||||
}
|
||||
|
||||
/// Get detailed status for all relays
|
||||
@@ -715,6 +908,20 @@ final class NostrRelayManager: ObservableObject {
|
||||
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
|
||||
func resetAllConnections() {
|
||||
@@ -767,7 +974,8 @@ private enum ParsedInbound {
|
||||
if array.count >= 3,
|
||||
let subId = array[1] as? String,
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -887,10 +1095,10 @@ struct NostrFilter: Encodable {
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000]
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
@@ -906,6 +1114,16 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location notes with neighbors: subscribe to multiple geohashes (center + neighbors)
|
||||
static func geohashNotes(_ geohashes: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [1]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": geohashes]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,27 @@ import CryptoKit
|
||||
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
|
||||
/// as per XChaCha20 construction.
|
||||
enum XChaCha20Poly1305Compat {
|
||||
|
||||
/// Errors that can occur during XChaCha20-Poly1305 operations
|
||||
enum Error: Swift.Error {
|
||||
case invalidKeyLength(expected: Int, got: Int)
|
||||
case invalidNonceLength(expected: Int, got: Int)
|
||||
}
|
||||
|
||||
struct SealBox {
|
||||
let ciphertext: Data
|
||||
let tag: Data
|
||||
}
|
||||
|
||||
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonce12)
|
||||
@@ -23,10 +34,14 @@ enum XChaCha20Poly1305Compat {
|
||||
}
|
||||
|
||||
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
|
||||
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
|
||||
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce24.count == 24 else {
|
||||
throw Error.invalidNonceLength(expected: 24, got: nonce24.count)
|
||||
}
|
||||
|
||||
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
|
||||
let subkey = try hchacha20(key: key, nonce16: Data(nonce24.prefix(16)))
|
||||
let nonce12 = derive12ByteNonce(from24: nonce24)
|
||||
let chachaKey = SymmetricKey(data: subkey)
|
||||
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
|
||||
@@ -43,10 +58,14 @@ enum XChaCha20Poly1305Compat {
|
||||
return out
|
||||
}
|
||||
|
||||
private static func hchacha20(key: Data, nonce16: Data) -> Data {
|
||||
private static func hchacha20(key: Data, nonce16: Data) throws -> Data {
|
||||
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
|
||||
precondition(key.count == 32)
|
||||
precondition(nonce16.count == 16)
|
||||
guard key.count == 32 else {
|
||||
throw Error.invalidKeyLength(expected: 32, got: key.count)
|
||||
}
|
||||
guard nonce16.count == 16 else {
|
||||
throw Error.invalidNonceLength(expected: 16, got: nonce16.count)
|
||||
}
|
||||
|
||||
// Constants "expand 32-byte k"
|
||||
var state: [UInt32] = [
|
||||
|
||||
@@ -1,330 +0,0 @@
|
||||
//
|
||||
// BinaryProtocol.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
///
|
||||
/// # BinaryProtocol
|
||||
///
|
||||
/// Low-level binary encoding and decoding for BitChat protocol messages.
|
||||
/// Optimized for Bluetooth LE's limited bandwidth and MTU constraints.
|
||||
///
|
||||
/// ## Overview
|
||||
/// BinaryProtocol implements an efficient binary wire format that minimizes
|
||||
/// overhead while maintaining extensibility. It handles:
|
||||
/// - Compact binary encoding with fixed headers
|
||||
/// - Optional field support via flags
|
||||
/// - Automatic compression for large payloads
|
||||
/// - Endianness handling for cross-platform compatibility
|
||||
///
|
||||
/// ## Wire Format
|
||||
/// ```
|
||||
/// Header (Fixed 13 bytes):
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
/// |Version | Type | TTL | Timestamp | Flags | PayloadLength |
|
||||
/// |1 byte |1 byte|1byte| 8 bytes | 1 byte| 2 bytes |
|
||||
/// +--------+------+-----+-----------+-------+----------------+
|
||||
///
|
||||
/// Variable sections:
|
||||
/// +----------+-------------+---------+------------+
|
||||
/// | SenderID | RecipientID | Payload | Signature |
|
||||
/// | 8 bytes | 8 bytes* | Variable| 64 bytes* |
|
||||
/// +----------+-------------+---------+------------+
|
||||
/// * Optional fields based on flags
|
||||
/// ```
|
||||
///
|
||||
/// ## Design Rationale
|
||||
/// The protocol is designed for:
|
||||
/// - **Efficiency**: Minimal overhead for small messages
|
||||
/// - **Flexibility**: Optional fields via flag bits
|
||||
/// - **Compatibility**: Network byte order (big-endian)
|
||||
/// - **Performance**: Zero-copy where possible
|
||||
///
|
||||
/// ## Compression Strategy
|
||||
/// - Automatic compression for payloads > 256 bytes
|
||||
/// - zlib compression for broad compatibility on Apple platforms
|
||||
/// - Original size stored for decompression
|
||||
/// - Flag bit indicates compressed payload
|
||||
///
|
||||
/// ## Flag Bits
|
||||
/// - Bit 0: Has recipient ID (directed message)
|
||||
/// - Bit 1: Has signature (authenticated message)
|
||||
/// - Bit 2: Is compressed (LZ4 compression applied)
|
||||
/// - Bits 3-7: Reserved for future use
|
||||
///
|
||||
/// ## Size Constraints
|
||||
/// - Maximum packet size: 65,535 bytes (16-bit length field)
|
||||
/// - Typical packet size: < 512 bytes (BLE MTU)
|
||||
/// - Minimum packet size: 21 bytes (header + sender ID)
|
||||
///
|
||||
/// ## Encoding Process
|
||||
/// 1. Construct header with fixed fields
|
||||
/// 2. Set appropriate flags
|
||||
/// 3. Compress payload if beneficial
|
||||
/// 4. Append variable-length fields
|
||||
/// 5. Calculate and append signature if needed
|
||||
///
|
||||
/// ## Decoding Process
|
||||
/// 1. Validate minimum packet size
|
||||
/// 2. Parse fixed header
|
||||
/// 3. Extract flags and determine field presence
|
||||
/// 4. Parse variable fields based on flags
|
||||
/// 5. Decompress payload if compressed
|
||||
/// 6. Verify signature if present
|
||||
///
|
||||
/// ## Error Handling
|
||||
/// - Graceful handling of malformed packets
|
||||
/// - Clear error messages for debugging
|
||||
/// - No crashes on invalid input
|
||||
/// - Logging of protocol violations
|
||||
///
|
||||
/// ## Performance Notes
|
||||
/// - Allocation-free for small messages
|
||||
/// - Streaming support for large payloads
|
||||
/// - Efficient bit manipulation
|
||||
/// - Platform-optimized byte swapping
|
||||
///
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func trimmingNullBytes() -> Data {
|
||||
// Find the first null byte
|
||||
if let nullIndex = self.firstIndex(of: 0) {
|
||||
return self.prefix(nullIndex)
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
/// Implements binary encoding and decoding for BitChat protocol messages.
|
||||
/// Provides static methods for converting between BitchatPacket objects and
|
||||
/// their binary wire format representation.
|
||||
/// - Note: All multi-byte values use network byte order (big-endian)
|
||||
struct BinaryProtocol {
|
||||
static let headerSize = 13
|
||||
static let senderIDSize = 8
|
||||
static let recipientIDSize = 8
|
||||
static let signatureSize = 64
|
||||
|
||||
struct Flags {
|
||||
static let hasRecipient: UInt8 = 0x01
|
||||
static let hasSignature: UInt8 = 0x02
|
||||
static let isCompressed: UInt8 = 0x04
|
||||
}
|
||||
|
||||
// Encode BitchatPacket to binary format
|
||||
static func encode(_ packet: BitchatPacket, padding: Bool = true) -> Data? {
|
||||
var data = Data()
|
||||
|
||||
|
||||
// Try to compress payload if beneficial
|
||||
var payload = packet.payload
|
||||
var originalPayloadSize: UInt16? = nil
|
||||
var isCompressed = false
|
||||
|
||||
if CompressionUtil.shouldCompress(payload) {
|
||||
if let compressedPayload = CompressionUtil.compress(payload) {
|
||||
// Store original size for decompression (2 bytes after payload)
|
||||
originalPayloadSize = UInt16(payload.count)
|
||||
payload = compressedPayload
|
||||
isCompressed = true
|
||||
|
||||
} else {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
|
||||
// Header
|
||||
// Reserve capacity to reduce reallocations. Estimate base size conservatively.
|
||||
// header(13) + sender(8) + opt recipient(8) + opt originalSize(2) + payload + opt signature(64) + up to 255 pad
|
||||
let estimatedPayload = payload.count + (isCompressed ? 2 : 0)
|
||||
let estimated = headerSize + senderIDSize + (packet.recipientID == nil ? 0 : recipientIDSize) + estimatedPayload + (packet.signature == nil ? 0 : signatureSize) + 255
|
||||
data.reserveCapacity(estimated)
|
||||
data.append(packet.version)
|
||||
data.append(packet.type)
|
||||
data.append(packet.ttl)
|
||||
|
||||
// Timestamp (8 bytes, big-endian)
|
||||
for i in (0..<8).reversed() {
|
||||
data.append(UInt8((packet.timestamp >> (i * 8)) & 0xFF))
|
||||
}
|
||||
|
||||
// Flags
|
||||
var flags: UInt8 = 0
|
||||
if packet.recipientID != nil {
|
||||
flags |= Flags.hasRecipient
|
||||
}
|
||||
if packet.signature != nil {
|
||||
flags |= Flags.hasSignature
|
||||
}
|
||||
if isCompressed {
|
||||
flags |= Flags.isCompressed
|
||||
}
|
||||
data.append(flags)
|
||||
|
||||
// Payload length (2 bytes, big-endian) - includes original size if compressed
|
||||
let payloadDataSize = payload.count + (isCompressed ? 2 : 0)
|
||||
let payloadLength = UInt16(payloadDataSize)
|
||||
|
||||
|
||||
data.append(UInt8((payloadLength >> 8) & 0xFF))
|
||||
data.append(UInt8(payloadLength & 0xFF))
|
||||
|
||||
// SenderID (exactly 8 bytes)
|
||||
let senderBytes = packet.senderID.prefix(senderIDSize)
|
||||
data.append(senderBytes)
|
||||
if senderBytes.count < senderIDSize {
|
||||
data.append(Data(repeating: 0, count: senderIDSize - senderBytes.count))
|
||||
}
|
||||
|
||||
// RecipientID (if present)
|
||||
if let recipientID = packet.recipientID {
|
||||
let recipientBytes = recipientID.prefix(recipientIDSize)
|
||||
data.append(recipientBytes)
|
||||
if recipientBytes.count < recipientIDSize {
|
||||
data.append(Data(repeating: 0, count: recipientIDSize - recipientBytes.count))
|
||||
}
|
||||
}
|
||||
|
||||
// Payload (with original size prepended if compressed)
|
||||
if isCompressed, let originalSize = originalPayloadSize {
|
||||
// Prepend original size (2 bytes, big-endian)
|
||||
data.append(UInt8((originalSize >> 8) & 0xFF))
|
||||
data.append(UInt8(originalSize & 0xFF))
|
||||
}
|
||||
data.append(payload)
|
||||
|
||||
// Signature (if present)
|
||||
if let signature = packet.signature {
|
||||
data.append(signature.prefix(signatureSize))
|
||||
}
|
||||
|
||||
|
||||
// Apply padding to standard block sizes for traffic analysis resistance
|
||||
if padding {
|
||||
let optimalSize = MessagePadding.optimalBlockSize(for: data.count)
|
||||
let paddedData = MessagePadding.pad(data, toSize: optimalSize)
|
||||
return paddedData
|
||||
} else {
|
||||
// Caller explicitly requested no padding (e.g., BLE write path)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// Decode binary data to BitchatPacket
|
||||
static func decode(_ data: Data) -> BitchatPacket? {
|
||||
// Try decode as-is first (robust when padding wasn't applied)
|
||||
if let pkt = decodeCore(data) { return pkt }
|
||||
// If that fails, try after removing padding
|
||||
let unpadded = MessagePadding.unpad(data)
|
||||
if unpadded as NSData === data as NSData { return nil }
|
||||
return decodeCore(unpadded)
|
||||
}
|
||||
|
||||
// Core decoding implementation used by decode(_:) with and without padding removal
|
||||
private static func decodeCore(_ raw: Data) -> BitchatPacket? {
|
||||
// Minimum size: header + senderID
|
||||
guard raw.count >= headerSize + senderIDSize else { return nil }
|
||||
|
||||
return raw.withUnsafeBytes { (buf: UnsafeRawBufferPointer) -> BitchatPacket? in
|
||||
guard let base = buf.baseAddress else { return nil }
|
||||
var offset = 0
|
||||
func require(_ n: Int) -> Bool { offset + n <= buf.count }
|
||||
// Read single byte
|
||||
func read8() -> UInt8? {
|
||||
guard require(1) else { return nil }
|
||||
let v = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self).pointee
|
||||
offset += 1
|
||||
return v
|
||||
}
|
||||
// Read big-endian 16-bit
|
||||
func read16() -> UInt16? {
|
||||
guard require(2) else { return nil }
|
||||
let p = base.advanced(by: offset).assumingMemoryBound(to: UInt8.self)
|
||||
let v = (UInt16(p[0]) << 8) | UInt16(p[1])
|
||||
offset += 2
|
||||
return v
|
||||
}
|
||||
// Copy N bytes into Data
|
||||
func readData(_ n: Int) -> Data? {
|
||||
guard require(n) else { return nil }
|
||||
let ptr = base.advanced(by: offset)
|
||||
let d = Data(bytes: ptr, count: n)
|
||||
offset += n
|
||||
return d
|
||||
}
|
||||
|
||||
// Version
|
||||
guard let version = read8(), version == 1 else { return nil }
|
||||
guard let type = read8() else { return nil }
|
||||
guard let ttl = read8() else { return nil }
|
||||
|
||||
// Timestamp 8 bytes BE
|
||||
guard require(8) else { return nil }
|
||||
var ts: UInt64 = 0
|
||||
for _ in 0..<8 {
|
||||
guard let b = read8() else { return nil }
|
||||
ts = (ts << 8) | UInt64(b)
|
||||
}
|
||||
|
||||
// Flags
|
||||
guard let flags = read8() else { return nil }
|
||||
let hasRecipient = (flags & Flags.hasRecipient) != 0
|
||||
let hasSignature = (flags & Flags.hasSignature) != 0
|
||||
let isCompressed = (flags & Flags.isCompressed) != 0
|
||||
|
||||
// Payload length
|
||||
guard let payloadLen = read16(), payloadLen <= 65535 else { return nil }
|
||||
|
||||
// SenderID
|
||||
guard let senderID = readData(senderIDSize) else { return nil }
|
||||
|
||||
// Recipient
|
||||
var recipientID: Data? = nil
|
||||
if hasRecipient {
|
||||
recipientID = readData(recipientIDSize)
|
||||
if recipientID == nil { return nil }
|
||||
}
|
||||
|
||||
// Payload
|
||||
let payload: Data
|
||||
if isCompressed {
|
||||
// Need original size (2 bytes)
|
||||
guard let origSize16 = read16() else { return nil }
|
||||
let originalSize = Int(origSize16)
|
||||
guard originalSize >= 0 && originalSize <= 1_048_576 else { return nil }
|
||||
let compSize = Int(payloadLen) - 2
|
||||
guard compSize >= 0, let compressed = readData(compSize) else { return nil }
|
||||
guard let decompressed = CompressionUtil.decompress(compressed, originalSize: originalSize),
|
||||
decompressed.count == originalSize else { return nil }
|
||||
payload = decompressed
|
||||
} else {
|
||||
guard let p = readData(Int(payloadLen)) else { return nil }
|
||||
payload = p
|
||||
}
|
||||
|
||||
// Signature
|
||||
var signature: Data? = nil
|
||||
if hasSignature {
|
||||
signature = readData(signatureSize)
|
||||
if signature == nil { return nil }
|
||||
}
|
||||
|
||||
guard offset <= buf.count else { return nil }
|
||||
|
||||
return BitchatPacket(
|
||||
type: type,
|
||||
senderID: senderID,
|
||||
recipientID: recipientID,
|
||||
timestamp: ts,
|
||||
payload: payload,
|
||||
signature: signature,
|
||||
ttl: ttl
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// BitchatFilePacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
|
||||
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
|
||||
/// Mirrors the Android client specification to ensure cross-platform interoperability.
|
||||
struct BitchatFilePacket {
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content: Data
|
||||
|
||||
/// Canonical TLV tags defined by the Android implementation.
|
||||
private enum TLVType: UInt8 {
|
||||
case fileName = 0x01
|
||||
case fileSize = 0x02
|
||||
case mimeType = 0x03
|
||||
case content = 0x04
|
||||
}
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
|
||||
if let name = fileName, let nameData = name.data(using: .utf8), nameData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.fileName.rawValue)
|
||||
appendBE(UInt16(nameData.count), into: &encoded)
|
||||
encoded.append(nameData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.fileSize.rawValue)
|
||||
appendBE(UInt16(4), into: &encoded)
|
||||
appendBE(UInt32(resolvedSize), into: &encoded)
|
||||
|
||||
if let mime = mimeType, let mimeData = mime.data(using: .utf8), mimeData.count <= Int(UInt16.max) {
|
||||
encoded.append(TLVType.mimeType.rawValue)
|
||||
appendBE(UInt16(mimeData.count), into: &encoded)
|
||||
encoded.append(mimeData)
|
||||
}
|
||||
|
||||
encoded.append(TLVType.content.rawValue)
|
||||
appendBE(UInt32(content.count), into: &encoded)
|
||||
encoded.append(content)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var fileName: String?
|
||||
var fileSize: UInt64?
|
||||
var mimeType: String?
|
||||
var content = Data()
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard cursor <= end else { return nil }
|
||||
let tlvType = TLVType(rawValue: typeRaw)
|
||||
|
||||
func readBigEndianLength(bytes: Int) -> Int? {
|
||||
guard data.distance(from: cursor, to: end) >= bytes else { return nil }
|
||||
// Use UInt64 to prevent integer overflow during shift operations
|
||||
var result: UInt64 = 0
|
||||
for _ in 0..<bytes {
|
||||
result = (result << 8) | UInt64(data[cursor])
|
||||
cursor = data.index(after: cursor)
|
||||
}
|
||||
// Safely convert to Int with overflow check
|
||||
guard result <= Int.max else { return nil }
|
||||
return Int(result)
|
||||
}
|
||||
|
||||
let length: Int?
|
||||
if tlvType == .content {
|
||||
let snapshot = cursor
|
||||
let canonical = readBigEndianLength(bytes: 4)
|
||||
if let canonical = canonical,
|
||||
canonical <= data.distance(from: cursor, to: end) {
|
||||
length = canonical
|
||||
} else {
|
||||
cursor = snapshot
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
} else {
|
||||
length = readBigEndianLength(bytes: 2)
|
||||
}
|
||||
|
||||
guard let tlvLength = length, tlvLength >= 0 else { return nil }
|
||||
guard data.distance(from: cursor, to: end) >= tlvLength else { return nil }
|
||||
|
||||
let valueStart = cursor
|
||||
cursor = data.index(cursor, offsetBy: tlvLength)
|
||||
let value = data[valueStart..<cursor]
|
||||
|
||||
switch tlvType {
|
||||
case .fileName:
|
||||
fileName = String(data: Data(value), encoding: .utf8)
|
||||
case .fileSize:
|
||||
if tlvLength == 4 || tlvLength == 8 {
|
||||
var size: UInt64 = 0
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
}
|
||||
case .mimeType:
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
case nil:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
mimeType: mimeType,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,38 +60,7 @@
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
// 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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
import BitFoundation
|
||||
|
||||
// MARK: - Noise Payload Types
|
||||
|
||||
@@ -129,35 +98,6 @@ enum LazyHandshakeState {
|
||||
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
|
||||
|
||||
protocol BitchatDelegate: AnyObject {
|
||||
@@ -176,7 +116,7 @@ protocol BitchatDelegate: AnyObject {
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
}
|
||||
|
||||
// Provide default implementation to make it effectively optional
|
||||
@@ -193,7 +133,7 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,4 +119,57 @@ enum Geohash {
|
||||
}
|
||||
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
|
||||
}
|
||||
|
||||
/// Returns all 8 neighboring geohash cells at the same precision.
|
||||
/// - Parameter geohash: Base32 geohash string.
|
||||
/// - Returns: Array of 8 neighboring geohashes (N, NE, E, SE, S, SW, W, NW order).
|
||||
static func neighbors(of geohash: String) -> [String] {
|
||||
guard !geohash.isEmpty else { return [] }
|
||||
|
||||
let precision = geohash.count
|
||||
let bounds = decodeBounds(geohash)
|
||||
let center = decodeCenter(geohash)
|
||||
|
||||
// Calculate cell dimensions
|
||||
let latHeight = bounds.latMax - bounds.latMin
|
||||
let lonWidth = bounds.lonMax - bounds.lonMin
|
||||
|
||||
// Helper to wrap longitude around ±180
|
||||
func wrapLongitude(_ lon: Double) -> Double {
|
||||
var wrapped = lon
|
||||
while wrapped > 180.0 { wrapped -= 360.0 }
|
||||
while wrapped < -180.0 { wrapped += 360.0 }
|
||||
return wrapped
|
||||
}
|
||||
|
||||
// Helper to clamp latitude to ±90
|
||||
func clampLatitude(_ lat: Double) -> Double {
|
||||
return max(-90.0, min(90.0, lat))
|
||||
}
|
||||
|
||||
// Calculate 8 neighbor centers
|
||||
let neighbors: [(lat: Double, lon: Double)] = [
|
||||
(center.lat + latHeight, center.lon), // N
|
||||
(center.lat + latHeight, center.lon + lonWidth), // NE
|
||||
(center.lat, center.lon + lonWidth), // E
|
||||
(center.lat - latHeight, center.lon + lonWidth), // SE
|
||||
(center.lat - latHeight, center.lon), // S
|
||||
(center.lat - latHeight, center.lon - lonWidth), // SW
|
||||
(center.lat, center.lon - lonWidth), // W
|
||||
(center.lat + latHeight, center.lon - lonWidth) // NW
|
||||
]
|
||||
|
||||
// Encode each neighbor, handling boundary conditions
|
||||
return neighbors.compactMap { neighbor in
|
||||
let lat = clampLatitude(neighbor.lat)
|
||||
let lon = wrapLongitude(neighbor.lon)
|
||||
|
||||
// Skip if we've crossed a pole (latitude clamped to boundary)
|
||||
if (neighbor.lat > 90.0 || neighbor.lat < -90.0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return encode(latitude: lat, longitude: lon, precision: precision)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,4 +116,18 @@ enum ChannelID: Equatable, Codable {
|
||||
case .location(let ch): return ch.geohash
|
||||
}
|
||||
}
|
||||
|
||||
var isMesh: Bool {
|
||||
switch self {
|
||||
case .mesh: true
|
||||
case .location: false
|
||||
}
|
||||
}
|
||||
|
||||
var isLocation: Bool {
|
||||
switch self {
|
||||
case .mesh: false
|
||||
case .location: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,13 @@ struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -35,6 +37,16 @@ struct AnnouncementPacket {
|
||||
data.append(TLVType.signingPublicKey.rawValue)
|
||||
data.append(UInt8(signingPublicKey.count))
|
||||
data.append(signingPublicKey)
|
||||
|
||||
// TLV for direct neighbors (optional)
|
||||
if let neighbors = directNeighbors, !neighbors.isEmpty {
|
||||
let neighborsData = neighbors.prefix(10).reduce(Data()) { $0 + $1 }
|
||||
if !neighborsData.isEmpty && neighborsData.count % 8 == 0 {
|
||||
data.append(TLVType.directNeighbors.rawValue)
|
||||
data.append(UInt8(neighborsData.count))
|
||||
data.append(neighborsData)
|
||||
}
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
@@ -44,6 +56,7 @@ struct AnnouncementPacket {
|
||||
var nickname: String?
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -63,6 +76,17 @@ struct AnnouncementPacket {
|
||||
noisePublicKey = Data(value)
|
||||
case .signingPublicKey:
|
||||
signingPublicKey = Data(value)
|
||||
case .directNeighbors:
|
||||
if length > 0 && length % 8 == 0 {
|
||||
var neighbors = [Data]()
|
||||
let count = length / 8
|
||||
for i in 0..<count {
|
||||
let start = value.startIndex + i * 8
|
||||
let end = start + 8
|
||||
neighbors.append(Data(value[start..<end]))
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -74,7 +98,8 @@ struct AnnouncementPacket {
|
||||
return AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//
|
||||
// MimeType.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Extensions for missing UTTypes
|
||||
|
||||
extension UTType {
|
||||
static let webP = UTType(importedAs: "image/webp")
|
||||
static let aac = UTType(importedAs: "audio/aac")
|
||||
static let m4a = UTType(importedAs: "audio/m4a")
|
||||
static let ogg = UTType(importedAs: "audio/ogg")
|
||||
}
|
||||
|
||||
// MARK: - MimeType Enum
|
||||
|
||||
enum MimeType: CaseIterable, Hashable {
|
||||
case jpeg
|
||||
case jpg
|
||||
case png
|
||||
case gif
|
||||
case webp
|
||||
case mp4Audio
|
||||
case m4a
|
||||
case aac
|
||||
case mpeg
|
||||
case mp3
|
||||
case wav
|
||||
case xWav
|
||||
case ogg
|
||||
case pdf
|
||||
case octetStream
|
||||
|
||||
var utType: UTType {
|
||||
switch self {
|
||||
case .jpeg, .jpg: .jpeg
|
||||
case .png: .png
|
||||
case .gif: .gif
|
||||
case .webp: .webP
|
||||
case .aac: .aac
|
||||
case .m4a: .m4a
|
||||
case .mp4Audio: .mpeg4Audio
|
||||
case .mp3, .mpeg: .mp3
|
||||
case .wav, .xWav: .wav
|
||||
case .ogg: .ogg
|
||||
case .pdf: .pdf
|
||||
case .octetStream: .data
|
||||
}
|
||||
}
|
||||
|
||||
var category: Category {
|
||||
switch self {
|
||||
case .jpeg, .jpg, .png, .gif, .webp:
|
||||
return .image
|
||||
case .aac, .m4a, .mp4Audio, .mpeg, .mp3, .wav, .xWav, .ogg:
|
||||
return .audio
|
||||
case .pdf, .octetStream:
|
||||
return .file
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var mimeString: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "image/jpeg"
|
||||
case .png: "image/png"
|
||||
case .gif: "image/gif"
|
||||
case .webp: "image/webp"
|
||||
case .mp4Audio: "audio/mp4"
|
||||
case .m4a: "audio/m4a"
|
||||
case .aac: "audio/aac"
|
||||
case .mpeg: "audio/mpeg"
|
||||
case .mp3: "audio/mp3"
|
||||
case .wav: "audio/wav"
|
||||
case .xWav: "audio/x-wav"
|
||||
case .ogg: "audio/ogg"
|
||||
case .pdf: "application/pdf"
|
||||
case .octetStream: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
var defaultExtension: String {
|
||||
switch self {
|
||||
case .jpeg, .jpg: "jpg"
|
||||
case .png: "png"
|
||||
case .webp: "webp"
|
||||
case .gif: "gif"
|
||||
case .mp4Audio, .m4a, .aac: "m4a"
|
||||
case .mpeg, .mp3: "mp3"
|
||||
case .wav, .xWav: "wav"
|
||||
case .ogg: "ogg"
|
||||
case .pdf: "pdf"
|
||||
case .octetStream: "bin"
|
||||
}
|
||||
}
|
||||
|
||||
static var allowed: Set<MimeType> = [
|
||||
.jpeg, .jpg, .png, .gif, .webp,
|
||||
.mp4Audio, .m4a, .aac, .mpeg, .mp3,
|
||||
.wav, .xWav, .ogg,
|
||||
.pdf, .octetStream
|
||||
]
|
||||
|
||||
var isAllowed: Bool {
|
||||
Self.allowed.contains(self)
|
||||
}
|
||||
|
||||
// MARK: - Byte signature validation
|
||||
func matches(data: Data) -> Bool {
|
||||
guard !data.isEmpty else { return false }
|
||||
|
||||
// Generic type → skip validation
|
||||
if self == .octetStream { return true }
|
||||
|
||||
switch self {
|
||||
case .jpeg, .jpg:
|
||||
return data.count >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF
|
||||
|
||||
case .png:
|
||||
return data.count >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A
|
||||
|
||||
case .gif:
|
||||
return data.count >= 6 && data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 &&
|
||||
data[3] == 0x38 && (data[4] == 0x37 || data[4] == 0x39) && data[5] == 0x61
|
||||
|
||||
case .webp:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50
|
||||
|
||||
case .m4a, .mp4Audio, .aac:
|
||||
// AVAudioRecorder output varies by platform - be lenient
|
||||
// Security: size already capped + sandboxed execution
|
||||
return data.count > 100
|
||||
|
||||
case .mpeg, .mp3:
|
||||
if data.count >= 3 && data[0] == 0x49 && data[1] == 0x44 && data[2] == 0x33 {
|
||||
return true // ID3 header
|
||||
}
|
||||
return data.count >= 2 && data[0] == 0xFF && (data[1] & 0xE0) == 0xE0
|
||||
|
||||
case .wav, .xWav:
|
||||
return data.count >= 12 &&
|
||||
data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x41 && data[10] == 0x56 && data[11] == 0x45
|
||||
|
||||
case .ogg:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x4F && data[1] == 0x67 && data[2] == 0x67 && data[3] == 0x53
|
||||
|
||||
case .pdf:
|
||||
return data.count >= 4 &&
|
||||
data[0] == 0x25 && data[1] == 0x50 && data[2] == 0x44 && data[3] == 0x46
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Initializers
|
||||
|
||||
init?(_ mimeString: String?) {
|
||||
guard let mimeString else { return nil }
|
||||
|
||||
let normalized = mimeString.lowercased()
|
||||
|
||||
// Direct match with our canonical list
|
||||
if let match = MimeType.allCases.first(where: { $0.mimeString == normalized }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
// Let UTType normalize aliases like "image/jpg", "audio/x-wav", etc.
|
||||
if let type = UTType(mimeType: normalized),
|
||||
let match = MimeType.allCases.first(where: { type.conforms(to: $0.utType) }) {
|
||||
self = match
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension MimeType {
|
||||
enum Category: String {
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
|
||||
/// Result of command processing
|
||||
enum CommandResult {
|
||||
@@ -15,15 +16,53 @@ enum CommandResult {
|
||||
case handled // Command handled, no message needed
|
||||
}
|
||||
|
||||
/// Simple struct for geo participant info used by CommandProcessor
|
||||
struct CommandGeoParticipant {
|
||||
let id: String // pubkey hex (lowercased)
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
protocol CommandContextProvider: AnyObject {
|
||||
// MARK: - State Properties
|
||||
var nickname: String { get }
|
||||
var activeChannel: ChannelID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
var blockedUsers: Set<String> { get }
|
||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
||||
var idBridge: NostrIdentityBridge { get }
|
||||
|
||||
// MARK: - Peer Lookup
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
||||
|
||||
// MARK: - Chat Actions
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
||||
func clearCurrentPublicTimeline()
|
||||
func sendPublicRaw(_ content: String)
|
||||
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@MainActor
|
||||
final class CommandProcessor {
|
||||
weak var chatViewModel: ChatViewModel?
|
||||
weak var contextProvider: CommandContextProvider?
|
||||
weak var meshService: Transport?
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
|
||||
init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.chatViewModel = chatViewModel
|
||||
|
||||
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
|
||||
self.contextProvider = contextProvider
|
||||
self.meshService = meshService
|
||||
self.identityManager = identityManager
|
||||
}
|
||||
@@ -37,12 +76,12 @@ final class CommandProcessor {
|
||||
|
||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||
let inGeoPublic: Bool = {
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .mesh: return false
|
||||
case .location: return true
|
||||
}
|
||||
}()
|
||||
let inGeoDM = chatViewModel?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
let inGeoDM = contextProvider?.selectedPrivateChatPeer?.isGeoDM == true
|
||||
|
||||
switch cmd {
|
||||
case "/m", "/msg":
|
||||
@@ -81,15 +120,15 @@ final class CommandProcessor {
|
||||
let targetName = String(parts[0])
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname) else {
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "'\(nickname)' not found")
|
||||
}
|
||||
|
||||
chatViewModel?.startPrivateChat(with: peerID)
|
||||
|
||||
|
||||
contextProvider?.startPrivateChat(with: peerID)
|
||||
|
||||
if parts.count > 1 {
|
||||
let message = String(parts[1])
|
||||
chatViewModel?.sendPrivateMessage(message, to: peerID)
|
||||
contextProvider?.sendPrivateMessage(message, to: peerID)
|
||||
}
|
||||
|
||||
return .success(message: "started private chat with \(nickname)")
|
||||
@@ -97,12 +136,12 @@ final class CommandProcessor {
|
||||
|
||||
private func handleWho() -> CommandResult {
|
||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||
switch LocationChannelManager.shared.selectedChannel {
|
||||
switch contextProvider?.activeChannel ?? .mesh {
|
||||
case .location(let ch):
|
||||
// Geohash context: show visible geohash participants (exclude self)
|
||||
guard let vm = chatViewModel else { return .success(message: "nobody around") }
|
||||
let myHex = (try? chatViewModel?.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.visibleGeohashPeople().filter { person in
|
||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||
let myHex = (try? vm.idBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
|
||||
let people = vm.getVisibleGeoParticipants().filter { person in
|
||||
if let me = myHex { return person.id.lowercased() != me }
|
||||
return true
|
||||
}
|
||||
@@ -120,30 +159,30 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = chatViewModel?.selectedPrivateChatPeer {
|
||||
chatViewModel?.privateChats[peerID]?.removeAll()
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.privateChats[peerID]?.removeAll()
|
||||
} else {
|
||||
chatViewModel?.clearCurrentPublicTimeline()
|
||||
contextProvider?.clearCurrentPublicTimeline()
|
||||
}
|
||||
return .handled
|
||||
}
|
||||
|
||||
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 {
|
||||
return .error(message: "usage: /\(command) <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let targetPeerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
let myNickname = chatViewModel?.nickname else {
|
||||
guard let targetPeerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let myNickname = contextProvider?.nickname else {
|
||||
return .error(message: "cannot \(command) \(nickname): not found")
|
||||
}
|
||||
|
||||
let emoteContent = "* \(emoji) \(myNickname) \(action) \(nickname)\(suffix) *"
|
||||
|
||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||
if contextProvider?.selectedPrivateChatPeer != nil {
|
||||
// In private chat
|
||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||
@@ -159,24 +198,24 @@ final class CommandProcessor {
|
||||
}
|
||||
}()
|
||||
let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)"
|
||||
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
contextProvider?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
|
||||
}
|
||||
} else {
|
||||
// In public chat: send to active public channel (mesh or geohash)
|
||||
chatViewModel?.sendPublicRaw(emoteContent)
|
||||
contextProvider?.sendPublicRaw(emoteContent)
|
||||
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||
contextProvider?.addPublicSystemMessage(publicEcho)
|
||||
}
|
||||
|
||||
return .handled
|
||||
}
|
||||
|
||||
private func handleBlock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
|
||||
if targetName.isEmpty {
|
||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||
let meshBlocked = chatViewModel?.blockedUsers ?? []
|
||||
let meshBlocked = contextProvider?.blockedUsers ?? []
|
||||
var blockedNicknames: [String] = []
|
||||
if let peers = meshService?.getPeerNicknames() {
|
||||
for (peerID, nickname) in peers {
|
||||
@@ -190,8 +229,8 @@ final class CommandProcessor {
|
||||
// Geohash blocked names (prefer visible display names; fallback to #suffix)
|
||||
let geoBlocked = Array(identityManager.getBlockedNostrPubkeys())
|
||||
var geoNames: [String] = []
|
||||
if let vm = chatViewModel {
|
||||
let visible = vm.visibleGeohashPeople()
|
||||
if let vm = contextProvider {
|
||||
let visible = vm.getVisibleGeoParticipants()
|
||||
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
|
||||
for pk in geoBlocked {
|
||||
if let name = visibleIndex[pk.lowercased()] {
|
||||
@@ -210,7 +249,7 @@ final class CommandProcessor {
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
@@ -235,7 +274,7 @@ final class CommandProcessor {
|
||||
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
|
||||
}
|
||||
// Mesh lookup failed; try geohash (Nostr) participant by display name
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is already blocked")
|
||||
}
|
||||
@@ -247,14 +286,14 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleUnblock(_ args: String) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /unblock <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
if let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
@@ -263,7 +302,7 @@ final class CommandProcessor {
|
||||
return .success(message: "unblocked \(nickname)")
|
||||
}
|
||||
// Try geohash unblock
|
||||
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
|
||||
if let pub = contextProvider?.nostrPubkeyForDisplayName(nickname) {
|
||||
if !identityManager.isNostrBlocked(pubkeyHexLowercased: pub) {
|
||||
return .success(message: "\(nickname) is not blocked")
|
||||
}
|
||||
@@ -274,14 +313,14 @@ final class CommandProcessor {
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
@@ -294,15 +333,15 @@ final class CommandProcessor {
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
chatViewModel?.toggleFavorite(peerID: peerID)
|
||||
chatViewModel?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@@ -26,17 +27,14 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
private static let storageKey = "chat.bitchat.favorites"
|
||||
private static let keychainService = "chat.bitchat.favorites"
|
||||
private let keychain: KeychainHelperProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
|
||||
@Published private(set) var mutualFavorites: Set<Data> = []
|
||||
|
||||
private let userDefaults = UserDefaults.standard
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainHelperProtocol = KeychainHelper()) {
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
#endif
|
||||
|
||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||
/// - Persistence: UserDefaults (JSON string array)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
final class GeohashBookmarksStore: ObservableObject {
|
||||
static let shared = GeohashBookmarksStore()
|
||||
|
||||
@Published private(set) var bookmarks: [String] = []
|
||||
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||
|
||||
private let storeKey = "locationChannel.bookmarks"
|
||||
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||
private var membership: Set<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#endif
|
||||
|
||||
private let storage: UserDefaults
|
||||
|
||||
init(storage: UserDefaults = .standard) {
|
||||
self.storage = storage
|
||||
load()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
bookmarks.insert(gh, at: 0)
|
||||
membership.insert(gh)
|
||||
persist()
|
||||
// Resolve and persist a friendly name once when added
|
||||
resolveNameIfNeeded(for: gh)
|
||||
}
|
||||
|
||||
func remove(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard membership.contains(gh) else { return }
|
||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||
membership.remove(gh)
|
||||
// Clean up stored name to avoid stale cache growth
|
||||
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||
persistNames()
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = storage.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
bookmarks = list
|
||||
membership = seen
|
||||
}
|
||||
// Load any saved names
|
||||
if let namesData = storage.data(forKey: namesStoreKey),
|
||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||
bookmarkNames = dict
|
||||
}
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
storage.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistNames() {
|
||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||
storage.set(data, forKey: namesStoreKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private static func normalize(_ s: String) -> String {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - Name Resolution
|
||||
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
|
||||
func resolveNameIfNeeded(for geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
if bookmarkNames[gh] != nil { return }
|
||||
#if os(iOS) || os(macOS)
|
||||
if resolving.contains(gh) { return }
|
||||
resolving.insert(gh)
|
||||
// For very coarse geohashes, sample multiple points to capture multiple admin areas
|
||||
if gh.count <= 2 {
|
||||
let b = Geohash.decodeBounds(gh)
|
||||
let pts: [CLLocation] = [
|
||||
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMin, longitude: b.lonMax),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMin),
|
||||
CLLocation(latitude: b.latMax, longitude: b.lonMax)
|
||||
]
|
||||
resolveCompositeAdminName(geohash: gh, points: pts)
|
||||
} else {
|
||||
let center = Geohash.decodeCenter(gh)
|
||||
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard let self = self else { return }
|
||||
defer { self.resolving.remove(gh) }
|
||||
if let pm = placemarks?.first {
|
||||
let name = Self.nameForGeohashLength(gh.count, from: pm)
|
||||
if let name = name, !name.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = name
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
|
||||
var uniqueAdmins = OrderedSet<String>()
|
||||
var idx = 0
|
||||
func step() {
|
||||
if idx >= points.count {
|
||||
// Compose up to 2 names joined by ' and '
|
||||
let finalName: String? = {
|
||||
let names = uniqueAdmins.array
|
||||
if names.count >= 2 { return names[0] + " and " + names[1] }
|
||||
return names.first
|
||||
}()
|
||||
if let finalName = finalName, !finalName.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
self.bookmarkNames[gh] = finalName
|
||||
self.persistNames()
|
||||
}
|
||||
}
|
||||
self.resolving.remove(gh)
|
||||
return
|
||||
}
|
||||
let loc = points[idx]
|
||||
idx += 1
|
||||
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
|
||||
guard self != nil else { return }
|
||||
if let pm = placemarks?.first {
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
uniqueAdmins.insert(admin)
|
||||
} else if let country = pm.country, !country.isEmpty {
|
||||
uniqueAdmins.insert(country)
|
||||
}
|
||||
}
|
||||
// Proceed to next point
|
||||
step()
|
||||
}
|
||||
}
|
||||
step()
|
||||
}
|
||||
|
||||
// Minimal ordered-set for stable joining
|
||||
private struct OrderedSet<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
private(set) var array: [Element] = []
|
||||
mutating func insert(_ element: Element) {
|
||||
if set.insert(element).inserted { array.append(element) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
|
||||
switch len {
|
||||
case 0...2:
|
||||
// Prefer administrative area if available at this coarse level
|
||||
return pm.administrativeArea ?? pm.country
|
||||
case 3...4:
|
||||
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
|
||||
case 5:
|
||||
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
|
||||
case 6...7:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
|
||||
default:
|
||||
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//
|
||||
// GeohashParticipantTracker.swift
|
||||
// bitchat
|
||||
//
|
||||
// Tracks participants in geohash-based location channels.
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a participant in a geohash channel
|
||||
public struct GeoPerson: Identifiable, Equatable, Sendable {
|
||||
public let id: String // pubkey hex (lowercased)
|
||||
public let displayName: String
|
||||
public let lastSeen: Date
|
||||
|
||||
public init(id: String, displayName: String, lastSeen: Date) {
|
||||
self.id = id
|
||||
self.displayName = displayName
|
||||
self.lastSeen = lastSeen
|
||||
}
|
||||
}
|
||||
|
||||
/// Protocol for resolving display names and checking block status
|
||||
@MainActor
|
||||
public protocol GeohashParticipantContext: AnyObject {
|
||||
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
|
||||
func displayNameForPubkey(_ pubkeyHex: String) -> String
|
||||
/// Returns true if the pubkey is blocked
|
||||
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
|
||||
}
|
||||
|
||||
/// Tracks participants across multiple geohash channels
|
||||
@MainActor
|
||||
public final class GeohashParticipantTracker: ObservableObject {
|
||||
|
||||
/// Activity cutoff duration (defaults to 5 minutes)
|
||||
public let activityCutoff: TimeInterval
|
||||
|
||||
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
|
||||
private var participants: [String: [String: Date]] = [:]
|
||||
|
||||
/// Currently visible people for the active geohash
|
||||
@Published public private(set) var visiblePeople: [GeoPerson] = []
|
||||
|
||||
/// The currently active geohash (if any)
|
||||
private var activeGeohash: String?
|
||||
|
||||
/// Context for display name resolution and block checking
|
||||
private weak var context: GeohashParticipantContext?
|
||||
|
||||
/// Timer for periodic refresh
|
||||
private var refreshTimer: Timer?
|
||||
|
||||
public init(activityCutoff: TimeInterval = -300) { // default 5 minutes
|
||||
self.activityCutoff = activityCutoff
|
||||
}
|
||||
|
||||
/// Configure with a context provider
|
||||
public func configure(context: GeohashParticipantContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Set the currently active geohash
|
||||
public func setActiveGeohash(_ geohash: String?) {
|
||||
activeGeohash = geohash
|
||||
if geohash == nil {
|
||||
visiblePeople = []
|
||||
} else {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity from a participant in the current active geohash
|
||||
public func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = activeGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
}
|
||||
|
||||
/// Record activity from a participant in a specific geohash
|
||||
public func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = participants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
participants[geohash] = map
|
||||
|
||||
// Always notify observers that state has changed so counts in UI update
|
||||
objectWillChange.send()
|
||||
|
||||
// Only refresh visible list if this geohash is currently active
|
||||
if activeGeohash == geohash {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (used when blocking)
|
||||
public func removeParticipant(pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
for (gh, var map) in participants {
|
||||
map.removeValue(forKey: key)
|
||||
participants[gh] = map
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Get participant count for a specific geohash
|
||||
public func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = participants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
}
|
||||
|
||||
/// Get the visible people list for the active geohash (read-only query)
|
||||
public func getVisiblePeople() -> [GeoPerson] {
|
||||
guard let gh = activeGeohash, let context = context else { return [] }
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = (participants[gh] ?? [:])
|
||||
.filter { $0.value >= cutoff }
|
||||
.filter { !context.isBlocked($0.key) }
|
||||
|
||||
return map
|
||||
.map { (pub, seen) in
|
||||
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
|
||||
}
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
}
|
||||
|
||||
/// Refresh the visible people list
|
||||
public func refresh() {
|
||||
visiblePeople = getVisiblePeople()
|
||||
}
|
||||
|
||||
/// Start the periodic refresh timer
|
||||
public func startRefreshTimer(interval: TimeInterval = 30.0) {
|
||||
stopRefreshTimer()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the periodic refresh timer
|
||||
public func stopRefreshTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
/// Clear all participant data
|
||||
public func clear() {
|
||||
participants.removeAll()
|
||||
visiblePeople = []
|
||||
}
|
||||
|
||||
/// Clear participant data for a specific geohash
|
||||
public func clear(geohash: String) {
|
||||
participants.removeValue(forKey: geohash)
|
||||
if activeGeohash == geohash {
|
||||
visiblePeople = []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//
|
||||
// GeohashPresenceService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
||||
// to geohash location channels.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import Tor
|
||||
|
||||
protocol GeohashPresenceTimerProtocol: AnyObject {
|
||||
var isValid: Bool { get }
|
||||
func invalidate()
|
||||
}
|
||||
|
||||
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
|
||||
private let base: Timer
|
||||
|
||||
init(base: Timer) {
|
||||
self.base = base
|
||||
}
|
||||
|
||||
var isValid: Bool { base.isValid }
|
||||
|
||||
func invalidate() {
|
||||
base.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Monitors location changes via LocationStateManager
|
||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
||||
@MainActor
|
||||
final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||
private let torIsReady: () -> Bool
|
||||
private let torIsForeground: () -> Bool
|
||||
private let deriveIdentity: (String) throws -> NostrIdentity
|
||||
private let relayLookup: (String, Int) -> [String]
|
||||
private let relaySender: (NostrEvent, [String]) -> Void
|
||||
private let sleeper: (UInt64) async -> Void
|
||||
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval
|
||||
private let loopMaxInterval: TimeInterval
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval
|
||||
private let burstMaxDelay: TimeInterval
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
GeohashChannelLevel.region.precision, // 2
|
||||
GeohashChannelLevel.province.precision, // 4
|
||||
GeohashChannelLevel.city.precision // 5
|
||||
]
|
||||
|
||||
private init() {
|
||||
let idBridge = NostrIdentityBridge()
|
||||
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
|
||||
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
|
||||
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
self.torIsReady = { TorManager.shared.isReady }
|
||||
self.torIsForeground = { TorManager.shared.isForeground() }
|
||||
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
|
||||
self.relayLookup = { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
}
|
||||
self.relaySender = { event, relays in
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
}
|
||||
self.sleeper = { nanoseconds in
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
}
|
||||
self.scheduleTimer = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
}
|
||||
self.loopMinInterval = 40.0
|
||||
self.loopMaxInterval = 80.0
|
||||
self.burstMinDelay = 2.0
|
||||
self.burstMaxDelay = 5.0
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
internal init(
|
||||
availableChannelsProvider: @escaping () -> [GeohashChannel],
|
||||
locationChanges: AnyPublisher<[GeohashChannel], Never>,
|
||||
torReadyPublisher: AnyPublisher<Void, Never>,
|
||||
torIsReady: @escaping () -> Bool,
|
||||
torIsForeground: @escaping () -> Bool,
|
||||
deriveIdentity: @escaping (String) throws -> NostrIdentity,
|
||||
relayLookup: @escaping (String, Int) -> [String],
|
||||
relaySender: @escaping (NostrEvent, [String]) -> Void,
|
||||
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
|
||||
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
|
||||
GeohashPresenceTimerAdapter(
|
||||
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
|
||||
action()
|
||||
}
|
||||
)
|
||||
},
|
||||
loopMinInterval: TimeInterval = 40.0,
|
||||
loopMaxInterval: TimeInterval = 80.0,
|
||||
burstMinDelay: TimeInterval = 2.0,
|
||||
burstMaxDelay: TimeInterval = 5.0
|
||||
) {
|
||||
self.availableChannelsProvider = availableChannelsProvider
|
||||
self.locationChanges = locationChanges
|
||||
self.torReadyPublisher = torReadyPublisher
|
||||
self.torIsReady = torIsReady
|
||||
self.torIsForeground = torIsForeground
|
||||
self.deriveIdentity = deriveIdentity
|
||||
self.relayLookup = relayLookup
|
||||
self.relaySender = relaySender
|
||||
self.sleeper = sleeper
|
||||
self.scheduleTimer = scheduleTimer
|
||||
self.loopMinInterval = loopMinInterval
|
||||
self.loopMaxInterval = loopMaxInterval
|
||||
self.burstMinDelay = burstMinDelay
|
||||
self.burstMaxDelay = burstMaxDelay
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
/// Start the service (safe to call multiple times)
|
||||
func start() {
|
||||
SecureLogger.info("Presence: service starting...", category: .session)
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
locationChanges
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
torReadyPublisher
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard torIsReady() else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !torIsForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = availableChannelsProvider()
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
||||
for channel in channels {
|
||||
// Check privacy restriction
|
||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Launch independent task for each channel's delay
|
||||
Task { @MainActor in
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
await self.sleeper(nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? deriveIdentity(geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: geohash,
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
|
||||
if !targetRelays.isEmpty {
|
||||
relaySender(event, targetRelays)
|
||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,21 +7,10 @@
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
final class KeychainManager: KeychainManagerProtocol {
|
||||
// Use consistent service name for all keychain items
|
||||
private let service = BitchatApp.bundleID
|
||||
@@ -46,7 +35,181 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
// MARK: - BCH-01-009: Methods with Proper Error Classification
|
||||
|
||||
/// Get identity key with detailed result for proper error handling
|
||||
/// Distinguishes between missing keys (expected) and critical failures
|
||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||
let fullKey = "identity_\(key)"
|
||||
return retrieveDataWithResult(forKey: fullKey)
|
||||
}
|
||||
|
||||
/// Save identity key with detailed result and retry logic for transient errors
|
||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||
let fullKey = "identity_\(key)"
|
||||
return saveDataWithResult(keyData, forKey: fullKey)
|
||||
}
|
||||
|
||||
/// Internal method to save data with detailed result and retry for transient errors
|
||||
private func saveDataWithResult(_ data: Data, forKey key: String, retryCount: Int = 2) -> KeychainSaveResult {
|
||||
// Delete any existing item first to ensure clean state
|
||||
_ = delete(forKey: key)
|
||||
|
||||
// Build base query
|
||||
var base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
|
||||
kSecAttrLabel as String: "bitchat-\(key)"
|
||||
]
|
||||
#if os(macOS)
|
||||
base[kSecAttrSynchronizable as String] = false
|
||||
#endif
|
||||
|
||||
func attempt(addAccessGroup: Bool) -> OSStatus {
|
||||
var query = base
|
||||
if addAccessGroup { query[kSecAttrAccessGroup as String] = appGroup }
|
||||
return SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var status = attempt(addAccessGroup: true)
|
||||
if status == -34018 { // Missing entitlement, retry without access group
|
||||
status = attempt(addAccessGroup: false)
|
||||
}
|
||||
#else
|
||||
let status = attempt(addAccessGroup: false)
|
||||
#endif
|
||||
|
||||
// Classify the result
|
||||
let result = classifySaveStatus(status)
|
||||
|
||||
// Log all outcomes consistently
|
||||
switch result {
|
||||
case .success:
|
||||
SecureLogger.debug("Keychain save succeeded for key: \(key)", category: .keychain)
|
||||
case .duplicateItem:
|
||||
SecureLogger.warning("Keychain save found duplicate for key: \(key)", category: .keychain)
|
||||
case .accessDenied:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
||||
case .deviceLocked:
|
||||
SecureLogger.warning("Device locked during keychain save for key: \(key)", category: .keychain)
|
||||
case .storageFull:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain storage full for key: \(key)", category: .keychain)
|
||||
case .otherError(let code):
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
||||
context: "Keychain save failed for key: \(key)", category: .keychain)
|
||||
}
|
||||
|
||||
// Retry transient errors with exponential backoff
|
||||
if result.isRecoverableError && retryCount > 0 {
|
||||
let delayMs = UInt32((3 - retryCount) * 100) // 100ms, 200ms backoff
|
||||
usleep(delayMs * 1000)
|
||||
SecureLogger.debug("Retrying keychain save for key: \(key), attempts remaining: \(retryCount)", category: .keychain)
|
||||
return saveDataWithResult(data, forKey: key, retryCount: retryCount - 1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Internal method to retrieve data with detailed result
|
||||
private func retrieveDataWithResult(forKey key: String) -> KeychainReadResult {
|
||||
let base: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecAttrService as String: service,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
func attempt(withAccessGroup: Bool) -> OSStatus {
|
||||
var q = base
|
||||
if withAccessGroup { q[kSecAttrAccessGroup as String] = appGroup }
|
||||
return SecItemCopyMatching(q as CFDictionary, &result)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
var status = attempt(withAccessGroup: true)
|
||||
if status == -34018 { status = attempt(withAccessGroup: false) }
|
||||
#else
|
||||
let status = attempt(withAccessGroup: false)
|
||||
#endif
|
||||
|
||||
// Classify the result
|
||||
let readResult = classifyReadStatus(status, data: result as? Data)
|
||||
|
||||
// Log all outcomes consistently
|
||||
switch readResult {
|
||||
case .success:
|
||||
SecureLogger.debug("Keychain read succeeded for key: \(key)", category: .keychain)
|
||||
case .itemNotFound:
|
||||
// Expected case - no logging needed for missing keys
|
||||
break
|
||||
case .accessDenied:
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(status)),
|
||||
context: "Keychain access denied for key: \(key)", category: .keychain)
|
||||
case .deviceLocked:
|
||||
SecureLogger.warning("Device locked during keychain read for key: \(key)", category: .keychain)
|
||||
case .authenticationFailed:
|
||||
SecureLogger.warning("Authentication failed for keychain read of key: \(key)", category: .keychain)
|
||||
case .otherError(let code):
|
||||
SecureLogger.error(NSError(domain: "Keychain", code: Int(code)),
|
||||
context: "Keychain read failed for key: \(key)", category: .keychain)
|
||||
}
|
||||
|
||||
return readResult
|
||||
}
|
||||
|
||||
/// Classify keychain read status into meaningful categories
|
||||
private func classifyReadStatus(_ status: OSStatus, data: Data?) -> KeychainReadResult {
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
if let data = data {
|
||||
return .success(data)
|
||||
}
|
||||
return .otherError(status)
|
||||
case errSecItemNotFound:
|
||||
return .itemNotFound
|
||||
case errSecInteractionNotAllowed:
|
||||
// Device is locked or in a state that doesn't allow keychain access
|
||||
return .deviceLocked
|
||||
case errSecAuthFailed:
|
||||
return .authenticationFailed
|
||||
case -34018: // errSecMissingEntitlement
|
||||
return .accessDenied
|
||||
case errSecNotAvailable:
|
||||
return .accessDenied
|
||||
default:
|
||||
return .otherError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify keychain save status into meaningful categories
|
||||
private func classifySaveStatus(_ status: OSStatus) -> KeychainSaveResult {
|
||||
switch status {
|
||||
case errSecSuccess:
|
||||
return .success
|
||||
case errSecDuplicateItem:
|
||||
return .duplicateItem
|
||||
case errSecInteractionNotAllowed:
|
||||
return .deviceLocked
|
||||
case -34018: // errSecMissingEntitlement
|
||||
return .accessDenied
|
||||
case errSecNotAvailable:
|
||||
return .accessDenied
|
||||
case errSecDiskFull:
|
||||
return .storageFull
|
||||
default:
|
||||
return .otherError(status)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Generic Operations
|
||||
|
||||
private func save(_ value: String, forKey key: String) -> Bool {
|
||||
@@ -309,9 +472,54 @@ final class KeychainManager: KeychainManagerProtocol {
|
||||
}
|
||||
|
||||
// MARK: - Debug
|
||||
|
||||
|
||||
func verifyIdentityKeyExists() -> Bool {
|
||||
let key = "identity_noiseStaticKey"
|
||||
return retrieveData(forKey: key) != nil
|
||||
}
|
||||
|
||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||
|
||||
/// Save data with a custom service name
|
||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecValueData as String: data
|
||||
]
|
||||
if let accessible = accessible {
|
||||
query[kSecAttrAccessible as String] = accessible
|
||||
}
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
SecItemAdd(query as CFDictionary, nil)
|
||||
}
|
||||
|
||||
/// Load data from a custom service
|
||||
func load(key: String, service customService: String) -> Data? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess else { return nil }
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Delete data from a custom service
|
||||
func delete(key: String, service customService: String) {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: customService,
|
||||
kSecAttrAccount as String: key
|
||||
]
|
||||
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,304 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
import CoreLocation
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationChannelManager()
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private let userDefaultsKey = "locationChannel.selected"
|
||||
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
||||
private var isGeocoding: Bool = false
|
||||
|
||||
// Published state for UI bindings
|
||||
@Published private(set) var permissionState: PermissionState = .notDetermined
|
||||
@Published private(set) var availableChannels: [GeohashChannel] = []
|
||||
@Published private(set) var selectedChannel: ChannelID = .mesh
|
||||
// True when the current location channel was selected via manual teleport
|
||||
@Published var teleported: Bool = false
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
||||
|
||||
// Persisted set of geohashes that were selected via teleport
|
||||
private var teleportedSet: Set<String> = []
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
||||
// Load selection
|
||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
// Load persisted teleported set
|
||||
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
||||
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
teleportedSet = Set(arr)
|
||||
}
|
||||
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
||||
// This avoids showing teleported for in-region channels during cold start.
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
// If we don't have location authorization at startup, fall back to persisted teleport state
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
break // will compute from location
|
||||
default:
|
||||
if case .location(let ch) = selectedChannel {
|
||||
teleported = teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, macOS 11.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
||||
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
||||
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
||||
guard permissionState == .authorized else { return }
|
||||
// Stop any previous polling timer
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
// Tighten accuracy and distance filter for live view
|
||||
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
||||
// Start continuous updates
|
||||
cl.startUpdatingLocation()
|
||||
// Request an immediate fix to populate UI without waiting for movement
|
||||
requestOneShotLocation()
|
||||
}
|
||||
|
||||
/// Stop continuous refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = nil
|
||||
cl.stopUpdatingLocation()
|
||||
// Restore more relaxed defaults for background/idle state
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||
}
|
||||
// Update teleported flag based on persisted state for immediate UI behavior
|
||||
switch channel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// If this geohash is in our current regional set, do NOT mark teleported.
|
||||
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport for this geohash to keep future selections clean
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fall back to persisted mark (set by deep link or manual teleport)
|
||||
self.teleported = self.teleportedSet.contains(ch.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
||||
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
||||
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
||||
}
|
||||
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
||||
Task { @MainActor in self.teleported = flag }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreLocation
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
// iOS < 14
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+ / macOS 11+
|
||||
@available(iOS 14.0, macOS 11.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
reverseGeocodeIfNeeded(location: loc)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
||||
result.append(GeohashChannel(level: level, geohash: gh))
|
||||
}
|
||||
Task { @MainActor in
|
||||
self.availableChannels = result
|
||||
// Recompute teleported status based on whether the selected geohash is in our regional set
|
||||
switch self.selectedChannel {
|
||||
case .mesh:
|
||||
self.teleported = false
|
||||
case .location(let ch):
|
||||
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
||||
let inRegional = result.contains { $0.geohash == ch.geohash }
|
||||
if inRegional {
|
||||
self.teleported = false
|
||||
// Clear persisted teleport flag if present
|
||||
if self.teleportedSet.contains(ch.geohash) {
|
||||
self.teleportedSet.remove(ch.geohash)
|
||||
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
||||
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.teleported = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
||||
// Always cancel previous to keep latest fresh while user moves
|
||||
geocoder.cancelGeocode()
|
||||
isGeocoding = true
|
||||
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
||||
guard let self = self else { return }
|
||||
self.isGeocoding = false
|
||||
if let pm = placemarks?.first {
|
||||
let names = self.namesByLevel(from: pm)
|
||||
Task { @MainActor in self.locationNames = names }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
||||
var dict: [GeohashChannelLevel: String] = [:]
|
||||
// Region (country)
|
||||
if let country = pm.country, !country.isEmpty {
|
||||
dict[.region] = country
|
||||
}
|
||||
// Province (state/province or county)
|
||||
if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.province] = admin
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.province] = subAdmin
|
||||
}
|
||||
// City (locality)
|
||||
if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.city] = locality
|
||||
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
||||
dict[.city] = subAdmin
|
||||
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
||||
dict[.city] = admin
|
||||
}
|
||||
// Neighborhood
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.neighborhood] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.neighborhood] = locality
|
||||
}
|
||||
// Block: reuse neighborhood/locality granularity
|
||||
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
||||
dict[.block] = subLocality
|
||||
} else if let locality = pm.locality, !locality.isEmpty {
|
||||
dict[.block] = locality
|
||||
}
|
||||
// Building: prefer place name/street/venue when available
|
||||
if let name = pm.name, !name.isEmpty {
|
||||
dict[.building] = name
|
||||
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
||||
dict[.building] = thoroughfare
|
||||
}
|
||||
return dict
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -1,104 +0,0 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
struct LocationNotesCounterDependencies {
|
||||
typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String]
|
||||
typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void
|
||||
typealias Unsubscribe = @MainActor (_ id: String) -> Void
|
||||
|
||||
var relayLookup: RelayLookup
|
||||
var subscribe: Subscribe
|
||||
var unsubscribe: Unsubscribe
|
||||
|
||||
static let live = LocationNotesCounterDependencies(
|
||||
relayLookup: { geohash, count in
|
||||
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
|
||||
},
|
||||
subscribe: { filter, id, relays, handler, onEOSE in
|
||||
NostrRelayManager.shared.subscribe(
|
||||
filter: filter,
|
||||
id: id,
|
||||
relayUrls: relays,
|
||||
handler: handler,
|
||||
onEOSE: onEOSE
|
||||
)
|
||||
},
|
||||
unsubscribe: { id in
|
||||
NostrRelayManager.shared.unsubscribe(id: id)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||
@MainActor
|
||||
final class LocationNotesCounter: ObservableObject {
|
||||
static let shared = LocationNotesCounter()
|
||||
|
||||
@Published private(set) var geohash: String? = nil
|
||||
@Published private(set) var count: Int? = 0
|
||||
@Published private(set) var initialLoadComplete: Bool = false
|
||||
@Published private(set) var relayAvailable: Bool = true
|
||||
|
||||
private var subscriptionID: String? = nil
|
||||
private var noteIDs = Set<String>()
|
||||
private let dependencies: LocationNotesCounterDependencies
|
||||
|
||||
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||
self.dependencies = dependencies
|
||||
}
|
||||
|
||||
init(testDependencies: LocationNotesCounterDependencies) {
|
||||
self.dependencies = testDependencies
|
||||
}
|
||||
|
||||
func subscribe(geohash gh: String) {
|
||||
let norm = gh.lowercased()
|
||||
if geohash == norm, subscriptionID != nil { return }
|
||||
// Validate geohash (building-level precision: 8 chars)
|
||||
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||
SecureLogger.warning("LocationNotesCounter: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||
return
|
||||
}
|
||||
// Unsubscribe previous without clearing count to avoid flicker
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = norm
|
||||
noteIDs.removeAll()
|
||||
initialLoadComplete = false
|
||||
relayAvailable = true
|
||||
|
||||
// Subscribe only to the building geohash (precision 8)
|
||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
||||
let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
relayAvailable = false
|
||||
initialLoadComplete = true
|
||||
count = 0
|
||||
SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptionID = subID
|
||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||
if !self.noteIDs.contains(event.id) {
|
||||
self.noteIDs.insert(event.id)
|
||||
self.count = self.noteIDs.count
|
||||
}
|
||||
}, { [weak self] in
|
||||
self?.initialLoadComplete = true
|
||||
})
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||
subscriptionID = nil
|
||||
geohash = nil
|
||||
count = 0
|
||||
noteIDs.removeAll()
|
||||
relayAvailable = true
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
var displayName: String {
|
||||
let suffix = String(pubkey.suffix(4))
|
||||
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let nick = nickname?.trimmedOrNilIfEmpty {
|
||||
return "\(nick)#\(suffix)"
|
||||
}
|
||||
return "anon#\(suffix)"
|
||||
@@ -163,14 +163,22 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
subscriptionID = subID
|
||||
initialLoadComplete = false
|
||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||
|
||||
// Subscribe to center + 8 neighbors (± 1 grid)
|
||||
let neighbors = Geohash.neighbors(of: geohash)
|
||||
let allGeohashes = [geohash] + neighbors
|
||||
let filter = NostrFilter.geohashNotes(allGeohashes, since: nil, limit: 200)
|
||||
|
||||
// Build a set of valid geohashes for tag matching (includes all 9 cells)
|
||||
let validGeohashes = Set(allGeohashes.map { $0.lowercased() })
|
||||
|
||||
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||
// Ensure matching tag
|
||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||
// Ensure matching tag - accept any of our 9 geohashes
|
||||
guard event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "g" && validGeohashes.contains(tag[1].lowercased())
|
||||
}) else { return }
|
||||
guard !self.noteIDs.contains(event.id) else { return }
|
||||
self.noteIDs.insert(event.id)
|
||||
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||
@@ -191,8 +199,7 @@ final class LocationNotesManager: ObservableObject {
|
||||
|
||||
/// Send a location note for the current geohash using the per-geohash identity.
|
||||
func send(content: String, nickname: String) {
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
state = .noRelays
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user