mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 18:45:20 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e868e3bbfb | ||
|
|
d371131ad5 | ||
|
|
d994ccf012 | ||
|
|
302c741d58 | ||
|
|
7ec84857eb | ||
|
|
68482c67d7 | ||
|
|
551a843691 | ||
|
|
fbc15ea08f | ||
|
|
0f23ed0a99 | ||
|
|
c583949031 | ||
|
|
d75700fa2b | ||
|
|
7149182c56 | ||
|
|
13b58aeaa9 | ||
|
|
b5b05977fb | ||
|
|
af6703cf24 | ||
|
|
eb13eec4c5 | ||
|
|
81c90b2924 | ||
|
|
8c368233c4 | ||
|
|
13ebaad42f | ||
|
|
10e3f574ab | ||
|
|
5f44c56a90 | ||
|
|
01ec4573f8 | ||
|
|
f86ae5e2ea | ||
|
|
2673d28686 | ||
|
|
03c357f048 | ||
|
|
64f91bb1d6 | ||
|
|
9ecda048e7 | ||
|
|
bdc31d3be3 | ||
|
|
6846b19d83 | ||
|
|
524a7e916b | ||
|
|
f2473f857b | ||
|
|
8cfee095d3 | ||
|
|
e4ec2ef3fe | ||
|
|
bd11940151 | ||
|
|
90273cee2d | ||
|
|
faae625e53 | ||
|
|
3a35b3acc2 | ||
|
|
787386c66d | ||
|
|
aeec15f054 | ||
|
|
f004f3e7ea | ||
|
|
8d938e8518 | ||
|
|
ea72212f66 | ||
|
|
3183703a63 | ||
|
|
2ffa709cca | ||
|
|
bf712a0610 | ||
|
|
78fb3f1bf6 | ||
|
|
f5af00be88 | ||
|
|
b2214e30f5 | ||
|
|
85063e9359 | ||
|
|
f67f728d79 | ||
|
|
b873b19104 | ||
|
|
0f7bcf17ff | ||
|
|
da2a12296e | ||
|
|
56bd100944 | ||
|
|
eb5bc96a3c | ||
|
|
d01538a2ea | ||
|
|
9abfab3248 | ||
|
|
0ae09f73d8 | ||
|
|
56dd12f3b1 | ||
|
|
f5caa1751a | ||
|
|
de906cb97c | ||
|
|
a41ec65f58 | ||
|
|
1fd2da18f5 | ||
|
|
c49a1b264e | ||
|
|
10c0391eaf | ||
|
|
3a94b57341 | ||
|
|
f8f780d2d6 | ||
|
|
c837afb818 | ||
|
|
47ef82f01a | ||
|
|
d1e5ce21a7 | ||
|
|
f2c1bb2131 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258659000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/Swift.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 14166264
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258662000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 18068
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754191141000000000
|
||||||
|
path: 'usr/lib/swift/SwiftOnoneSupport.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 1224
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258669000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_Concurrency.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 699544
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192470000000000
|
||||||
|
path: 'usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 364219
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
dependencies:
|
||||||
|
- mtime: 1757258664000000000
|
||||||
|
path: '/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx/prebuilt-modules/26.0/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftmodule'
|
||||||
|
size: 83568
|
||||||
|
- mtime: 1754189697000000000
|
||||||
|
path: 'usr/lib/swift/Swift.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 2261306
|
||||||
|
sdk_relative: true
|
||||||
|
- mtime: 1754192532000000000
|
||||||
|
path: 'usr/lib/swift/_StringProcessing.swiftmodule/arm64e-apple-macos.swiftinterface'
|
||||||
|
size: 24507
|
||||||
|
sdk_relative: true
|
||||||
|
version: 1
|
||||||
|
...
|
||||||
@@ -24,4 +24,4 @@ jobs:
|
|||||||
run: swift build
|
run: swift build
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: swift test --parallel --disable-swift-testing # so it only runs xctests: https://github.com/swiftlang/swift-package-manager/issues/8529#issuecomment-2815711345
|
run: swift test --parallel
|
||||||
|
|||||||
+2
-4
@@ -9,9 +9,6 @@ plans/
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
## User settings
|
|
||||||
xcuserdata/
|
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
*.xccheckout
|
*.xccheckout
|
||||||
@@ -57,7 +54,8 @@ iOSInjectionProject/
|
|||||||
|
|
||||||
## Xcode project
|
## Xcode project
|
||||||
*.xcodeproj/project.xcworkspace/
|
*.xcodeproj/project.xcworkspace/
|
||||||
*.xcodeproj/xcshareddata/
|
## Xcode User settings
|
||||||
|
xcuserdata/
|
||||||
|
|
||||||
## Python
|
## Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.4.2
|
MARKETING_VERSION = 1.4.4
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+9
-14
@@ -4,6 +4,7 @@ import PackageDescription
|
|||||||
|
|
||||||
let package = Package(
|
let package = Package(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
|
defaultLocalization: "en",
|
||||||
platforms: [
|
platforms: [
|
||||||
.iOS(.v16),
|
.iOS(.v16),
|
||||||
.macOS(.v13)
|
.macOS(.v13)
|
||||||
@@ -15,6 +16,7 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
|
.package(path: "localPackages/Tor"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
|
||||||
],
|
],
|
||||||
@@ -24,8 +26,7 @@ let package = Package(
|
|||||||
dependencies: [
|
dependencies: [
|
||||||
.product(name: "P256K", package: "swift-secp256k1"),
|
.product(name: "P256K", package: "swift-secp256k1"),
|
||||||
.product(name: "BitLogger", package: "BitLogger"),
|
.product(name: "BitLogger", package: "BitLogger"),
|
||||||
.target(name: "TorC"),
|
.product(name: "Tor", package: "Tor")
|
||||||
.target(name: "tor-nolzma")
|
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
@@ -33,21 +34,12 @@ let package = Package(
|
|||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard"
|
||||||
"Services/Tor/C/"
|
|
||||||
],
|
],
|
||||||
linkerSettings: [
|
resources: [
|
||||||
.linkedLibrary("z")
|
.process("Localizable.xcstrings")
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.target(
|
|
||||||
name: "TorC",
|
|
||||||
path: "bitchat/Services/Tor/C"
|
|
||||||
),
|
|
||||||
.binaryTarget(
|
|
||||||
name: "tor-nolzma",
|
|
||||||
path: "Frameworks/tor-nolzma.xcframework"
|
|
||||||
),
|
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: ["bitchat"],
|
||||||
@@ -55,6 +47,9 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md"
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
.process("Localization")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
|
|||||||
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
|
||||||
|
|
||||||
> [!WARNING]
|
> [!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](http://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
|
|||||||
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
|
||||||
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
|
||||||
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
|
||||||
- **Private Message End-to-End Encryption**: [Noise Protocol](http://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
|
||||||
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
|
||||||
- **Universal App**: Native support for iOS and macOS
|
- **Universal App**: Native support for iOS and macOS
|
||||||
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
- **Emergency Wipe**: Triple-tap to instantly clear all data
|
||||||
@@ -116,3 +116,10 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
|||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
Want to try this on macos: `just run` will set it up and run from source.
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||||
|
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||||
|
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
|
||||||
|
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
|
||||||
|
|||||||
@@ -184,6 +184,28 @@ To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary for
|
|||||||
|
|
||||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
theme: dark
|
||||||
|
---
|
||||||
|
---
|
||||||
|
title: "BitchatPacket"
|
||||||
|
---
|
||||||
|
packet
|
||||||
|
+8: "Version"
|
||||||
|
+8: "Type"
|
||||||
|
+8: "TTL"
|
||||||
|
+64: "Timestamp"
|
||||||
|
+8: "Flags"
|
||||||
|
+16: "Payload Length"
|
||||||
|
+64: "Sender ID"
|
||||||
|
+64: "Recipient ID (optional)"
|
||||||
|
+48: "Payload (variable)"
|
||||||
|
+64: "Signature (optional)"
|
||||||
|
```
|
||||||
|
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||||
|
|
||||||
### 6.2. Application Message Format (`BitchatMessage`)
|
### 6.2. Application Message Format (`BitchatMessage`)
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||||
@@ -198,6 +220,25 @@ For packets of type `message`, the payload is a binary-serialized `BitchatMessag
|
|||||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
theme: dark
|
||||||
|
---
|
||||||
|
---
|
||||||
|
title: "BitchatMessage"
|
||||||
|
---
|
||||||
|
packet
|
||||||
|
+8: "Flags"
|
||||||
|
+64: "Timestamp"
|
||||||
|
+24: "ID (variable)"
|
||||||
|
+32: "Sender (variable)"
|
||||||
|
+32: "Content (variable)"
|
||||||
|
+32: "Original Sender (variable) (optional)"
|
||||||
|
+32: "Recipient Nickname (variable) (optional)"
|
||||||
|
```
|
||||||
|
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Message Routing and Propagation
|
## 7. Message Routing and Propagation
|
||||||
|
|||||||
Generated
+44
-21
@@ -7,15 +7,14 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */; };
|
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; };
|
|
||||||
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA802E7706A80032EA8A /* Tor */; };
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */; };
|
||||||
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@@ -58,11 +57,10 @@
|
|||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; };
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
|
||||||
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "tor-nolzma.xcframework"; path = "localPackages/Tor/Frameworks/tor-nolzma.xcframework"; sourceTree = "<group>"; };
|
||||||
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -87,7 +85,6 @@
|
|||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
Info.plist,
|
Info.plist,
|
||||||
LaunchScreen.storyboard,
|
LaunchScreen.storyboard,
|
||||||
Services/Tor/C/include/.gitkeep,
|
|
||||||
);
|
);
|
||||||
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
target = 0576A29205865664C0937536 /* bitchat_macOS */;
|
||||||
};
|
};
|
||||||
@@ -136,32 +133,22 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
048A88812E76FD18000FBCDD /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A02E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
|
A6F183FD2E948783006A9046 /* tor-nolzma.xcframework in Frameworks */,
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */,
|
|
||||||
0481A3A12E744D6300FC845E /* tor-nolzma.xcframework in Frameworks */,
|
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
0481A3542E6D877600FC845E /* Frameworks */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
0481A39F2E744D6300FC845E /* tor-nolzma.xcframework */,
|
|
||||||
0481A35C2E6DA18600FC845E /* libz.tbd */,
|
|
||||||
);
|
|
||||||
path = Frameworks;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
18198ED912AAF495D8AF7763 = {
|
18198ED912AAF495D8AF7763 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -170,8 +157,8 @@
|
|||||||
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
|
||||||
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
A6E32D412E762EAE0032EA8A /* bitchatTests */,
|
||||||
A6E367C92E76469E0032EA8A /* Configs */,
|
A6E367C92E76469E0032EA8A /* Configs */,
|
||||||
0481A3542E6D877600FC845E /* Frameworks */,
|
|
||||||
9F37F9F2C353B58AC809E93B /* Products */,
|
9F37F9F2C353B58AC809E93B /* Products */,
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -187,6 +174,14 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A6F183FB2E948783006A9046 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A6F183FC2E948783006A9046 /* tor-nolzma.xcframework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -207,6 +202,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -284,6 +280,7 @@
|
|||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -304,12 +301,26 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
Base,
|
Base,
|
||||||
en,
|
en,
|
||||||
|
es,
|
||||||
|
ar,
|
||||||
|
de,
|
||||||
|
fr,
|
||||||
|
he,
|
||||||
|
id,
|
||||||
|
it,
|
||||||
|
ja,
|
||||||
|
ne,
|
||||||
|
"pt-BR",
|
||||||
|
ru,
|
||||||
|
uk,
|
||||||
|
"zh-Hans",
|
||||||
);
|
);
|
||||||
mainGroup = 18198ED912AAF495D8AF7763;
|
mainGroup = 18198ED912AAF495D8AF7763;
|
||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
|
||||||
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -867,6 +878,10 @@
|
|||||||
isa = XCLocalSwiftPackageReference;
|
isa = XCLocalSwiftPackageReference;
|
||||||
relativePath = localPackages/BitLogger;
|
relativePath = localPackages/BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Tor" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Tor;
|
||||||
|
};
|
||||||
/* End XCLocalSwiftPackageReference section */
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
@@ -894,6 +909,14 @@
|
|||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = BitLogger;
|
productName = BitLogger;
|
||||||
};
|
};
|
||||||
|
A6E3EA7E2E7706720032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
|
A6E3EA802E7706A80032EA8A /* Tor */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Tor;
|
||||||
|
};
|
||||||
B1D9136AA0083366353BFA2F /* P256K */ = {
|
B1D9136AA0083366353BFA2F /* P256K */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "57CA17A36A2532A6CFF367BB"
|
||||||
|
BuildableName = "bitchatShareExtension.appex"
|
||||||
|
BlueprintName = "bitchatShareExtension"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
codeCoverageEnabled = "YES"
|
||||||
|
onlyGenerateCoverageForSpecifiedTargets = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<CodeCoverageTargets>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</CodeCoverageTargets>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "6CB97DF2EA57234CB3E563B8"
|
||||||
|
BuildableName = "bitchatTests_iOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "-DBITCHAT_DEV_ALLOW_CLEARNET"
|
||||||
|
value = ""
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "AF077EA0474EDEDE2C72716C"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_iOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1640"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<MacroExpansion>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</MacroExpansion>
|
||||||
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "47FF23248747DD7CB666CB91"
|
||||||
|
BuildableName = "bitchatTests_macOS.xctest"
|
||||||
|
BlueprintName = "bitchatTests_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
<EnvironmentVariables>
|
||||||
|
<EnvironmentVariable
|
||||||
|
key = "BITCHAT_LOG_LEVEL"
|
||||||
|
value = "debug"
|
||||||
|
isEnabled = "YES">
|
||||||
|
</EnvironmentVariable>
|
||||||
|
</EnvironmentVariables>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "0576A29205865664C0937536"
|
||||||
|
BuildableName = "bitchat.app"
|
||||||
|
BlueprintName = "bitchat_macOS"
|
||||||
|
ReferencedContainer = "container:bitchat.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
+21
-19
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@@ -48,9 +49,10 @@ struct BitchatApp: App {
|
|||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
||||||
|
let nickname = chatViewModel.nickname
|
||||||
DispatchQueue.global(qos: .utility).async {
|
DispatchQueue.global(qos: .utility).async {
|
||||||
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
appDelegate.chatViewModel = chatViewModel
|
appDelegate.chatViewModel = chatViewModel
|
||||||
@@ -236,29 +238,29 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor [weak self] in
|
||||||
if identifier.hasPrefix("private-") {
|
guard let self = self else {
|
||||||
// Get peer ID from userInfo
|
completionHandler([.banner, .sound])
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
return
|
||||||
// Don't show notification if the private chat is already open
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer == peerID {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
// Check if this is a private message notification and chat already open
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
if identifier.hasPrefix("private-"),
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
let peerID = userInfo["peerID"] as? String,
|
||||||
let deep = userInfo["deeplink"] as? String,
|
self.chatViewModel?.selectedPrivateChatPeer == peerID {
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
completionHandler([])
|
||||||
return
|
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,
|
||||||
|
case .location(let ch) = LocationChannelManager.shared.selectedChannel,
|
||||||
|
ch.geohash == gh {
|
||||||
|
completionHandler([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completionHandler([.banner, .sound])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ import Foundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
let peerID: String // 8 random bytes
|
let peerID: PeerID // 8 random bytes
|
||||||
let sessionStart: Date
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
@@ -158,23 +158,6 @@ struct IdentityCache: Codable {
|
|||||||
var version: Int = 1
|
var version: Int = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Identity Resolution
|
|
||||||
|
|
||||||
enum IdentityHint {
|
|
||||||
case unknown
|
|
||||||
case likelyKnown(fingerprint: String)
|
|
||||||
case ambiguous(candidates: Set<String>)
|
|
||||||
case verified(fingerprint: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Pending Actions
|
|
||||||
|
|
||||||
struct PendingActions {
|
|
||||||
var toggleFavorite: Bool?
|
|
||||||
var setTrustLevel: TrustLevel?
|
|
||||||
var setPetname: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
// MARK: - Migration Support
|
// MARK: - Migration Support
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Cryptographic Identities
|
// MARK: Cryptographic Identities
|
||||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?)
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity]
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity]
|
||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
@@ -121,12 +121,12 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func getBlockedNostrPubkeys() -> Set<String>
|
func getBlockedNostrPubkeys() -> Set<String>
|
||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState)
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: String)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Verification
|
// MARK: Verification
|
||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
@@ -143,7 +143,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
private let encryptionKeyName = "identityCacheEncryptionKey"
|
private let encryptionKeyName = "identityCacheEncryptionKey"
|
||||||
|
|
||||||
// In-memory state
|
// In-memory state
|
||||||
private var ephemeralSessions: [String: EphemeralIdentity] = [:]
|
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||||
private var cache: IdentityCache = IdentityCache()
|
private var cache: IdentityCache = IdentityCache()
|
||||||
|
|
||||||
@@ -321,11 +321,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
/// Find cryptographic identities whose fingerprint prefix matches a peerID (16-hex) short ID
|
||||||
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
|
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
// Defensive: ensure hex and correct length
|
// Defensive: ensure hex and correct length
|
||||||
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
|
guard peerID.isShort else { return [] }
|
||||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
|
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,7 +455,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: - Ephemeral Session Management
|
// MARK: - Ephemeral Session Management
|
||||||
|
|
||||||
func registerEphemeralSession(peerID: String, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -465,7 +465,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateHandshakeState(peerID: String, state: HandshakeState) {
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEphemeralSession(peerID: String) {
|
func removeEphemeralSession(peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
|
|||||||
let originalSender: String?
|
let originalSender: String?
|
||||||
let isPrivate: Bool
|
let isPrivate: Bool
|
||||||
let recipientNickname: String?
|
let recipientNickname: String?
|
||||||
let senderPeer: Peer?
|
let senderPeerID: PeerID?
|
||||||
let mentions: [String]? // Array of mentioned nicknames
|
let mentions: [String]? // Array of mentioned nicknames
|
||||||
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
var deliveryStatus: DeliveryStatus? // Delivery tracking
|
||||||
|
|
||||||
@@ -39,8 +39,7 @@ final class BitchatMessage: Codable {
|
|||||||
// Codable implementation
|
// Codable implementation
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, sender, content, timestamp, isRelay, originalSender
|
case id, sender, content, timestamp, isRelay, originalSender
|
||||||
case isPrivate, recipientNickname, mentions, deliveryStatus
|
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
|
||||||
case senderPeer = "senderPeerID" // backwards compatibility
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -52,7 +51,7 @@ final class BitchatMessage: Codable {
|
|||||||
originalSender: String? = nil,
|
originalSender: String? = nil,
|
||||||
isPrivate: Bool = false,
|
isPrivate: Bool = false,
|
||||||
recipientNickname: String? = nil,
|
recipientNickname: String? = nil,
|
||||||
senderPeer: Peer? = nil,
|
senderPeerID: PeerID? = nil,
|
||||||
mentions: [String]? = nil,
|
mentions: [String]? = nil,
|
||||||
deliveryStatus: DeliveryStatus? = nil
|
deliveryStatus: DeliveryStatus? = nil
|
||||||
) {
|
) {
|
||||||
@@ -64,7 +63,7 @@ final class BitchatMessage: Codable {
|
|||||||
self.originalSender = originalSender
|
self.originalSender = originalSender
|
||||||
self.isPrivate = isPrivate
|
self.isPrivate = isPrivate
|
||||||
self.recipientNickname = recipientNickname
|
self.recipientNickname = recipientNickname
|
||||||
self.senderPeer = senderPeer
|
self.senderPeerID = senderPeerID
|
||||||
self.mentions = mentions
|
self.mentions = mentions
|
||||||
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
|
||||||
}
|
}
|
||||||
@@ -82,7 +81,7 @@ extension BitchatMessage: Equatable {
|
|||||||
lhs.originalSender == rhs.originalSender &&
|
lhs.originalSender == rhs.originalSender &&
|
||||||
lhs.isPrivate == rhs.isPrivate &&
|
lhs.isPrivate == rhs.isPrivate &&
|
||||||
lhs.recipientNickname == rhs.recipientNickname &&
|
lhs.recipientNickname == rhs.recipientNickname &&
|
||||||
lhs.senderPeer == rhs.senderPeer &&
|
lhs.senderPeerID == rhs.senderPeerID &&
|
||||||
lhs.mentions == rhs.mentions &&
|
lhs.mentions == rhs.mentions &&
|
||||||
lhs.deliveryStatus == rhs.deliveryStatus
|
lhs.deliveryStatus == rhs.deliveryStatus
|
||||||
}
|
}
|
||||||
@@ -114,7 +113,7 @@ extension BitchatMessage {
|
|||||||
if isPrivate { flags |= 0x02 }
|
if isPrivate { flags |= 0x02 }
|
||||||
if originalSender != nil { flags |= 0x04 }
|
if originalSender != nil { flags |= 0x04 }
|
||||||
if recipientNickname != nil { flags |= 0x08 }
|
if recipientNickname != nil { flags |= 0x08 }
|
||||||
if senderPeer != nil { flags |= 0x10 }
|
if senderPeerID != nil { flags |= 0x10 }
|
||||||
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
|
||||||
|
|
||||||
data.append(flags)
|
data.append(flags)
|
||||||
@@ -164,7 +163,7 @@ extension BitchatMessage {
|
|||||||
data.append(recipData.prefix(255))
|
data.append(recipData.prefix(255))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let peerData = senderPeer?.data {
|
if let peerData = senderPeerID?.id.data(using: .utf8) {
|
||||||
data.append(UInt8(min(peerData.count, 255)))
|
data.append(UInt8(min(peerData.count, 255)))
|
||||||
data.append(peerData.prefix(255))
|
data.append(peerData.prefix(255))
|
||||||
}
|
}
|
||||||
@@ -277,11 +276,11 @@ extension BitchatMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var senderPeer: Peer?
|
var senderPeerID: PeerID?
|
||||||
if hasSenderPeerID && offset < dataCopy.count {
|
if hasSenderPeerID && offset < dataCopy.count {
|
||||||
let length = Int(dataCopy[offset]); offset += 1
|
let length = Int(dataCopy[offset]); offset += 1
|
||||||
if offset + length <= dataCopy.count {
|
if offset + length <= dataCopy.count {
|
||||||
senderPeer = Peer(data: dataCopy[offset..<offset+length])
|
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
|
||||||
offset += length
|
offset += length
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +314,7 @@ extension BitchatMessage {
|
|||||||
originalSender: originalSender,
|
originalSender: originalSender,
|
||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
recipientNickname: recipientNickname,
|
recipientNickname: recipientNickname,
|
||||||
senderPeer: senderPeer,
|
senderPeerID: senderPeerID,
|
||||||
mentions: mentions
|
mentions: mentions
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct BitchatPacket: Codable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience initializer for new binary format
|
// Convenience initializer for new binary format
|
||||||
init(type: UInt8, ttl: UInt8, senderID: String, payload: Data) {
|
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
|
||||||
self.version = 1
|
self.version = 1
|
||||||
self.type = type
|
self.type = type
|
||||||
// Convert hex string peer ID to binary data (8 bytes)
|
// Convert hex string peer ID to binary data (8 bytes)
|
||||||
var senderData = Data()
|
var senderData = Data()
|
||||||
var tempID = senderID
|
var tempID = senderID.id
|
||||||
while tempID.count >= 2 {
|
while tempID.count >= 2 {
|
||||||
let hexByte = String(tempID.prefix(2))
|
let hexByte = String(tempID.prefix(2))
|
||||||
if let byte = UInt8(hexByte, radix: 16) {
|
if let byte = UInt8(hexByte, radix: 16) {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import Foundation
|
|||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Identifiable, Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
let id: String // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
@@ -51,7 +51,7 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Display helpers
|
// Display helpers
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
nickname.isEmpty ? String(id.prefix(8)) : nickname
|
nickname.isEmpty ? String(peerID.id.prefix(8)) : nickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var statusIcon: String {
|
var statusIcon: String {
|
||||||
@@ -73,14 +73,14 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
|
|
||||||
// Initialize from mesh service data
|
// Initialize from mesh service data
|
||||||
init(
|
init(
|
||||||
id: String,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
self.lastSeen = lastSeen
|
self.lastSeen = lastSeen
|
||||||
@@ -93,8 +93,6 @@ struct BitchatPeer: Identifiable, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
static func == (lhs: BitchatPeer, rhs: BitchatPeer) -> Bool {
|
||||||
lhs.id == rhs.id
|
lhs.peerID == rhs.peerID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
//
|
|
||||||
// Peer.swift
|
|
||||||
// BitLogger
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
struct Peer: Equatable, Hashable {
|
|
||||||
let id: String
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Peer {
|
|
||||||
var data: Data? {
|
|
||||||
id.data(using: .utf8)
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNostr: Bool {
|
|
||||||
id.hasPrefix("nostr")
|
|
||||||
}
|
|
||||||
|
|
||||||
var isNostrColon: Bool {
|
|
||||||
id.hasPrefix("nostr:")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ExpressibleByStringLiteral
|
|
||||||
|
|
||||||
extension Peer: ExpressibleByStringLiteral {
|
|
||||||
init(stringLiteral value: String) {
|
|
||||||
self.init(str: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - ExpressibleByStringInterpolation
|
|
||||||
|
|
||||||
extension Peer: ExpressibleByStringInterpolation {
|
|
||||||
init(extendedGraphemeClusterLiteral value: String) {
|
|
||||||
self.init(str: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Codable
|
|
||||||
|
|
||||||
extension Peer: Codable {
|
|
||||||
init(from decoder: any Decoder) throws {
|
|
||||||
id = try decoder.singleValueContainer().decode(String.self)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode(to encoder: any Encoder) throws {
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Convenience Inits
|
|
||||||
|
|
||||||
extension Peer {
|
|
||||||
init(str: String) {
|
|
||||||
id = str.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
init(str: String.SubSequence) {
|
|
||||||
self.init(str: String(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(data: Data) {
|
|
||||||
guard let str = String(data: data, encoding: .utf8) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.init(str: str)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
//
|
||||||
|
// PeerID.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct PeerID: Equatable, Hashable {
|
||||||
|
enum Prefix: String, CaseIterable {
|
||||||
|
/// When no prefix is provided
|
||||||
|
case empty = ""
|
||||||
|
/// `"mesh:"`
|
||||||
|
case mesh = "mesh:"
|
||||||
|
/// `"name:"`
|
||||||
|
case name = "name:"
|
||||||
|
/// `"noise:"` (+ 64 characters hex)
|
||||||
|
case noise = "noise:"
|
||||||
|
/// `"nostr_"` (+ 16 characters hex)
|
||||||
|
case geoDM = "nostr_"
|
||||||
|
/// `"nostr:"` (+ 8 characters hex)
|
||||||
|
case geoChat = "nostr:"
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefix: Prefix
|
||||||
|
|
||||||
|
/// Returns the actual value without any prefix
|
||||||
|
let bare: String
|
||||||
|
|
||||||
|
/// Returns the full `id` value by combining `(prefix + bare)`
|
||||||
|
var id: String { prefix.rawValue + bare }
|
||||||
|
|
||||||
|
// Private so the callers have to go through a convenience init
|
||||||
|
private init(prefix: Prefix, bare: any StringProtocol) {
|
||||||
|
self.prefix = prefix
|
||||||
|
self.bare = String(bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Inits
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Convenience init to create GeoDM PeerID by appending `"nostr_"` to the first 16 characters of `pubKey`
|
||||||
|
init(nostr_ pubKey: String) {
|
||||||
|
self.init(prefix: .geoDM, bare: pubKey.prefix(TransportConfig.nostrConvKeyPrefixLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create GeoChat PeerID by appending `"nostr:"` to the first 8 characters of `pubKey`
|
||||||
|
init(nostr pubKey: String) {
|
||||||
|
self.init(prefix: .geoChat, bare: pubKey.prefix(TransportConfig.nostrShortKeyDisplayLength))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID from String/Substring by splitting it into prefix and bare parts
|
||||||
|
init(str: any StringProtocol) {
|
||||||
|
if let prefix = Prefix.allCases.first(where: { $0 != .empty && str.hasPrefix($0.rawValue) }) {
|
||||||
|
self.init(prefix: prefix, bare: String(str).dropFirst(prefix.rawValue.count))
|
||||||
|
} else {
|
||||||
|
self.init(prefix: .empty, bare: str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to handle `Optional<String>`
|
||||||
|
init?(str: (any StringProtocol)?) {
|
||||||
|
guard let str else { return nil }
|
||||||
|
self.init(str: str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to create PeerID by converting Data to String
|
||||||
|
init?(data: Data) {
|
||||||
|
self.init(str: String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience init to "hide" hex-encoding implementation detail
|
||||||
|
init(hexData: Data) {
|
||||||
|
self.init(str: hexData.hexEncodedString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Noise Public Key Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
/// Derive the stable 16-hex peer ID from a Noise static public key
|
||||||
|
init(publicKey: Data) {
|
||||||
|
self.init(str: publicKey.sha256Fingerprint().prefix(16))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
||||||
|
func toShort() -> PeerID {
|
||||||
|
if let noiseKey {
|
||||||
|
return PeerID(publicKey: noiseKey)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Codable
|
||||||
|
|
||||||
|
extension PeerID: Codable {
|
||||||
|
init(from decoder: any Decoder) throws {
|
||||||
|
self.init(str: try decoder.singleValueContainer().decode(String.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
var isEmpty: Bool {
|
||||||
|
id.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr:`"
|
||||||
|
var isGeoChat: Bool {
|
||||||
|
prefix == .geoChat
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `id` starts with "`nostr_`"
|
||||||
|
var isGeoDM: Bool {
|
||||||
|
prefix == .geoDM
|
||||||
|
}
|
||||||
|
|
||||||
|
func toPercentEncoded() -> String {
|
||||||
|
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
extension PeerID {
|
||||||
|
private enum Constants {
|
||||||
|
static let maxIDLength = 64
|
||||||
|
static let hexIDLength = 16 // 8 bytes = 16 hex chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
||||||
|
var isValid: Bool {
|
||||||
|
if prefix != .empty {
|
||||||
|
return PeerID(str: bare).isValid
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept short routing IDs (exact 16-hex) or Full Noise key hex (exact 64-hex)
|
||||||
|
if isShort || isNoiseKeyHex {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// If length equals short or full but isn't valid hex, reject
|
||||||
|
if id.count == Constants.hexIDLength || id.count == Constants.maxIDLength {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
||||||
|
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
||||||
|
return !id.isEmpty &&
|
||||||
|
id.count < Constants.maxIDLength &&
|
||||||
|
id.rangeOfCharacter(from: validCharset.inverted) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Short routing IDs (exact 16-hex)
|
||||||
|
var isShort: Bool {
|
||||||
|
bare.count == Constants.hexIDLength && Data(hexString: bare) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key hex (exact 64-hex)
|
||||||
|
var isNoiseKeyHex: Bool {
|
||||||
|
noiseKey != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full Noise key (exact 64-hex) as Data
|
||||||
|
var noiseKey: Data? {
|
||||||
|
guard bare.count == Constants.maxIDLength else { return nil }
|
||||||
|
return Data(hexString: bare)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Comparable
|
||||||
|
|
||||||
|
extension PeerID: Comparable {
|
||||||
|
static func < (lhs: PeerID, rhs: PeerID) -> Bool {
|
||||||
|
lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - String Interop Helpers
|
||||||
|
|
||||||
|
// MARK: CustomStringConvertible
|
||||||
|
|
||||||
|
extension PeerID: CustomStringConvertible {
|
||||||
|
/// So it returns the actual `id` like before even inside another String
|
||||||
|
var description: String {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Custom Equatable w/ String & Optionality
|
||||||
|
|
||||||
|
// PeerID <> String
|
||||||
|
extension Optional where Wrapped == PeerID {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id == rhs }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<String>) -> Bool { lhs?.id != rhs }
|
||||||
|
}
|
||||||
|
|
||||||
|
// String <> PeerID
|
||||||
|
extension Optional where Wrapped == String {
|
||||||
|
static func ==(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs == rhs?.id }
|
||||||
|
static func !=(lhs: Optional<Wrapped>, rhs: Optional<PeerID>) -> Bool { lhs != rhs?.id }
|
||||||
|
}
|
||||||
@@ -79,7 +79,7 @@ struct ReadReceipt: Codable {
|
|||||||
|
|
||||||
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
guard let readerIDData = dataCopy.readFixedBytes(at: &offset, count: 8) else { return nil }
|
||||||
let readerID = readerIDData.hexEncodedString()
|
let readerID = readerIDData.hexEncodedString()
|
||||||
guard InputValidator.validatePeerID(readerID) else { return nil }
|
guard PeerID(str: readerID).isValid else { return nil }
|
||||||
|
|
||||||
guard let timestamp = dataCopy.readDate(at: &offset),
|
guard let timestamp = dataCopy.readDate(at: &offset),
|
||||||
InputValidator.validateTimestamp(timestamp),
|
InputValidator.validateTimestamp(timestamp),
|
||||||
|
|||||||
@@ -1,358 +0,0 @@
|
|||||||
//
|
|
||||||
// NoiseHandshakeCoordinator.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Coordinates Noise handshakes to prevent race conditions and ensure reliable encryption establishment
|
|
||||||
final class NoiseHandshakeCoordinator {
|
|
||||||
|
|
||||||
// MARK: - Handshake State
|
|
||||||
|
|
||||||
enum HandshakeState: Equatable {
|
|
||||||
case idle
|
|
||||||
case waitingToInitiate(since: Date)
|
|
||||||
case initiating(attempt: Int, lastAttempt: Date)
|
|
||||||
case responding(since: Date)
|
|
||||||
case waitingForResponse(messagesSent: [Data], timeout: Date)
|
|
||||||
case established(since: Date)
|
|
||||||
case failed(reason: String, canRetry: Bool, lastAttempt: Date)
|
|
||||||
|
|
||||||
var isActive: Bool {
|
|
||||||
switch self {
|
|
||||||
case .idle, .established, .failed:
|
|
||||||
return false
|
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Properties
|
|
||||||
|
|
||||||
private var handshakeStates: [String: HandshakeState] = [:]
|
|
||||||
private var handshakeQueue = DispatchQueue(label: "chat.bitchat.noise.handshake", attributes: .concurrent)
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
private let maxHandshakeAttempts = 3
|
|
||||||
private let handshakeTimeout: TimeInterval = 10.0
|
|
||||||
private let retryDelay: TimeInterval = 2.0
|
|
||||||
private let minTimeBetweenHandshakes: TimeInterval = 1.0 // Reduced from 5.0 for faster recovery
|
|
||||||
private let establishedSessionTTL: TimeInterval = 300.0 // 5 minutes - sessions older than this can be cleaned up
|
|
||||||
private let maxEstablishedSessions = 50 // Limit total established sessions
|
|
||||||
|
|
||||||
// Track handshake messages to detect duplicates
|
|
||||||
private var processedHandshakeMessages: Set<Data> = []
|
|
||||||
private let messageHistoryLimit = 100
|
|
||||||
|
|
||||||
// MARK: - Role Determination
|
|
||||||
|
|
||||||
/// Deterministically determine who should initiate the handshake
|
|
||||||
/// Lower peer ID becomes the initiator to prevent simultaneous attempts
|
|
||||||
func determineHandshakeRole(myPeerID: String, remotePeerID: String) -> NoiseRole {
|
|
||||||
// Use simple string comparison for deterministic ordering
|
|
||||||
return myPeerID < remotePeerID ? .initiator : .responder
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if we should initiate handshake with a peer
|
|
||||||
func shouldInitiateHandshake(myPeerID: String, remotePeerID: String, forceIfStale: Bool = false) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
// Check if we're already in an active handshake
|
|
||||||
if let state = handshakeStates[remotePeerID], state.isActive {
|
|
||||||
// Check if the handshake is stale and we should force a new one
|
|
||||||
if forceIfStale {
|
|
||||||
switch state {
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
if Date().timeIntervalSince(lastAttempt) > handshakeTimeout {
|
|
||||||
SecureLogger.warning("Forcing new handshake with \(remotePeerID) - previous stuck in initiating", category: .handshake)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.debug("Already in active handshake with \(remotePeerID), state: \(state)", category: .handshake)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check role
|
|
||||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
|
||||||
if role != .initiator {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we've failed recently and can't retry yet
|
|
||||||
if case .failed(_, let canRetry, let lastAttempt) = handshakeStates[remotePeerID] {
|
|
||||||
if !canRetry {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if Date().timeIntervalSince(lastAttempt) < retryDelay {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that we're initiating a handshake
|
|
||||||
func recordHandshakeInitiation(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let attempt = self.getCurrentAttempt(for: peerID) + 1
|
|
||||||
self.handshakeStates[peerID] = .initiating(attempt: attempt, lastAttempt: Date())
|
|
||||||
SecureLogger.info("Recording handshake initiation with \(peerID), attempt \(attempt)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record that we're responding to a handshake
|
|
||||||
func recordHandshakeResponse(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates[peerID] = .responding(since: Date())
|
|
||||||
SecureLogger.info("Recording handshake response to \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record successful handshake completion
|
|
||||||
func recordHandshakeSuccess(peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates[peerID] = .established(since: Date())
|
|
||||||
SecureLogger.info("Handshake successfully established with \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record handshake failure
|
|
||||||
func recordHandshakeFailure(peerID: String, reason: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let attempts = self.getCurrentAttempt(for: peerID)
|
|
||||||
let canRetry = attempts < self.maxHandshakeAttempts
|
|
||||||
self.handshakeStates[peerID] = .failed(reason: reason, canRetry: canRetry, lastAttempt: Date())
|
|
||||||
SecureLogger.warning("Handshake failed with \(peerID): \(reason), canRetry: \(canRetry)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if we should accept an incoming handshake initiation
|
|
||||||
func shouldAcceptHandshakeInitiation(myPeerID: String, remotePeerID: String) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
// If we're already established, reject new handshakes
|
|
||||||
if case .established = handshakeStates[remotePeerID] {
|
|
||||||
SecureLogger.debug("Rejecting handshake from \(remotePeerID) - already established", category: .handshake)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let role = determineHandshakeRole(myPeerID: myPeerID, remotePeerID: remotePeerID)
|
|
||||||
|
|
||||||
// If we're the initiator and already initiating, this is a race condition
|
|
||||||
if role == .initiator {
|
|
||||||
if case .initiating = handshakeStates[remotePeerID] {
|
|
||||||
// They shouldn't be initiating, but accept it to recover from race condition
|
|
||||||
SecureLogger.warning("Accepting handshake from \(remotePeerID) despite being initiator (race condition recovery)", category: .handshake)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we're the responder, we should accept
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this is a duplicate handshake message
|
|
||||||
func isDuplicateHandshakeMessage(_ data: Data) -> Bool {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
if processedHandshakeMessages.contains(data) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add to processed messages with size limit
|
|
||||||
if processedHandshakeMessages.count >= messageHistoryLimit {
|
|
||||||
processedHandshakeMessages.removeAll()
|
|
||||||
}
|
|
||||||
processedHandshakeMessages.insert(data)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get time to wait before next handshake attempt
|
|
||||||
func getRetryDelay(for peerID: String) -> TimeInterval? {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
guard let state = handshakeStates[peerID] else { return nil }
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case .failed(_, let canRetry, let lastAttempt):
|
|
||||||
if !canRetry { return nil }
|
|
||||||
let timeSinceFailure = Date().timeIntervalSince(lastAttempt)
|
|
||||||
if timeSinceFailure >= retryDelay {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return retryDelay - timeSinceFailure
|
|
||||||
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
let timeSinceAttempt = Date().timeIntervalSince(lastAttempt)
|
|
||||||
if timeSinceAttempt >= minTimeBetweenHandshakes {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return minTimeBetweenHandshakes - timeSinceAttempt
|
|
||||||
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reset handshake state for a peer
|
|
||||||
func resetHandshakeState(for peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
self.handshakeStates.removeValue(forKey: peerID)
|
|
||||||
SecureLogger.debug("Reset handshake state for \(peerID)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clean up stale handshake states and old established sessions
|
|
||||||
func cleanupStaleHandshakes(staleTimeout: TimeInterval = 30.0) -> [String] {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
let now = Date()
|
|
||||||
var stalePeerIDs: [String] = []
|
|
||||||
var establishedSessions: [(peerID: String, since: Date)] = []
|
|
||||||
|
|
||||||
for (peerID, state) in handshakeStates {
|
|
||||||
var isStale = false
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case .initiating(_, let lastAttempt):
|
|
||||||
if now.timeIntervalSince(lastAttempt) > staleTimeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .responding(let since):
|
|
||||||
if now.timeIntervalSince(since) > staleTimeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .waitingForResponse(_, let timeout):
|
|
||||||
if now > timeout {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
case .established(let since):
|
|
||||||
// Track established sessions for potential cleanup
|
|
||||||
establishedSessions.append((peerID, since))
|
|
||||||
// Clean up very old established sessions
|
|
||||||
if now.timeIntervalSince(since) > establishedSessionTTL {
|
|
||||||
isStale = true
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if isStale {
|
|
||||||
stalePeerIDs.append(peerID)
|
|
||||||
SecureLogger.warning("Found stale handshake state for \(peerID): \(state)", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have too many established sessions, clean up the oldest ones
|
|
||||||
if establishedSessions.count > maxEstablishedSessions {
|
|
||||||
// Sort by age (oldest first)
|
|
||||||
let sortedSessions = establishedSessions.sorted { $0.since < $1.since }
|
|
||||||
let sessionsToRemove = sortedSessions.count - maxEstablishedSessions
|
|
||||||
|
|
||||||
for i in 0..<sessionsToRemove {
|
|
||||||
let peerID = sortedSessions[i].peerID
|
|
||||||
stalePeerIDs.append(peerID)
|
|
||||||
SecureLogger.info("Removing old established session for \(peerID) to maintain session limit", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up stale states
|
|
||||||
for peerID in stalePeerIDs {
|
|
||||||
handshakeStates.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !stalePeerIDs.isEmpty {
|
|
||||||
SecureLogger.info("Cleaned up \(stalePeerIDs.count) stale handshake states", category: .handshake)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stalePeerIDs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current handshake state
|
|
||||||
func getHandshakeState(for peerID: String) -> HandshakeState {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
return handshakeStates[peerID] ?? .idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get current retry count for a peer
|
|
||||||
func getRetryCount(for peerID: String) -> Int {
|
|
||||||
return handshakeQueue.sync {
|
|
||||||
switch handshakeStates[peerID] {
|
|
||||||
case .initiating(let attempt, _):
|
|
||||||
return attempt - 1 // Attempts start at 1, retries start at 0
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Increment retry count for a peer
|
|
||||||
func incrementRetryCount(for peerID: String) {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
let currentAttempt = self.getCurrentAttempt(for: peerID)
|
|
||||||
self.handshakeStates[peerID] = .initiating(attempt: currentAttempt + 1, lastAttempt: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
|
||||||
|
|
||||||
private func getCurrentAttempt(for peerID: String) -> Int {
|
|
||||||
switch handshakeStates[peerID] {
|
|
||||||
case .initiating(let attempt, _):
|
|
||||||
return attempt
|
|
||||||
case .failed(_, _, _):
|
|
||||||
// Count previous attempts
|
|
||||||
return 1 // Simplified for now
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Log current handshake states for debugging
|
|
||||||
func logHandshakeStates() {
|
|
||||||
handshakeQueue.sync {
|
|
||||||
SecureLogger.debug("=== Handshake States ===", category: .handshake)
|
|
||||||
for (peerID, state) in handshakeStates {
|
|
||||||
let stateDesc: String
|
|
||||||
switch state {
|
|
||||||
case .idle:
|
|
||||||
stateDesc = "idle"
|
|
||||||
case .waitingToInitiate(let since):
|
|
||||||
stateDesc = "waiting to initiate (since \(since))"
|
|
||||||
case .initiating(let attempt, let lastAttempt):
|
|
||||||
stateDesc = "initiating (attempt \(attempt), last: \(lastAttempt))"
|
|
||||||
case .responding(let since):
|
|
||||||
stateDesc = "responding (since: \(since))"
|
|
||||||
case .waitingForResponse(let messages, let timeout):
|
|
||||||
stateDesc = "waiting for response (\(messages.count) messages, timeout: \(timeout))"
|
|
||||||
case .established(let since):
|
|
||||||
stateDesc = "established (since \(since))"
|
|
||||||
case .failed(let reason, let canRetry, let lastAttempt):
|
|
||||||
stateDesc = "failed: \(reason) (canRetry: \(canRetry), last: \(lastAttempt))"
|
|
||||||
}
|
|
||||||
SecureLogger.debug(" \(peerID): \(stateDesc)", category: .handshake)
|
|
||||||
}
|
|
||||||
SecureLogger.debug("========================", category: .handshake)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all handshake states - used during panic mode
|
|
||||||
func clearAllHandshakeStates() {
|
|
||||||
handshakeQueue.async(flags: .barrier) {
|
|
||||||
SecureLogger.warning("Clearing all handshake states for panic mode", category: .handshake)
|
|
||||||
self.handshakeStates.removeAll()
|
|
||||||
self.processedHandshakeMessages.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -397,7 +397,7 @@ final class NoiseSymmetricState {
|
|||||||
if nameData.count <= 32 {
|
if nameData.count <= 32 {
|
||||||
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
self.hash = nameData + Data(repeating: 0, count: 32 - nameData.count)
|
||||||
} else {
|
} else {
|
||||||
self.hash = Data(SHA256.hash(data: nameData))
|
self.hash = nameData.sha256Hash()
|
||||||
}
|
}
|
||||||
self.chainingKey = self.hash
|
self.chainingKey = self.hash
|
||||||
}
|
}
|
||||||
@@ -410,7 +410,7 @@ final class NoiseSymmetricState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func mixHash(_ data: Data) {
|
func mixHash(_ data: Data) {
|
||||||
hash = Data(SHA256.hash(data: hash + data))
|
hash = (hash + data).sha256Hash()
|
||||||
}
|
}
|
||||||
|
|
||||||
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
func mixKeyAndHash(_ inputKeyMaterial: Data) {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Security Constants
|
// MARK: - Security Constants
|
||||||
|
|
||||||
@@ -53,11 +52,6 @@ struct NoiseSecurityValidator {
|
|||||||
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
static func validateHandshakeMessageSize(_ data: Data) -> Bool {
|
||||||
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate peer ID format using unified validator
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
return InputValidator.validatePeerID(peerID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Enhanced Noise Session with Security
|
// MARK: - Enhanced Noise Session with Security
|
||||||
@@ -137,8 +131,8 @@ final class SecureNoiseSession: NoiseSession {
|
|||||||
// MARK: - Rate Limiter
|
// MARK: - Rate Limiter
|
||||||
|
|
||||||
final class NoiseRateLimiter {
|
final class NoiseRateLimiter {
|
||||||
private var handshakeTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var handshakeTimestamps: [PeerID: [Date]] = [:]
|
||||||
private var messageTimestamps: [String: [Date]] = [:] // peerID -> timestamps
|
private var messageTimestamps: [PeerID: [Date]] = [:]
|
||||||
|
|
||||||
// Global rate limiting
|
// Global rate limiting
|
||||||
private var globalHandshakeTimestamps: [Date] = []
|
private var globalHandshakeTimestamps: [Date] = []
|
||||||
@@ -146,7 +140,7 @@ final class NoiseRateLimiter {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "chat.bitchat.noise.ratelimit", attributes: .concurrent)
|
||||||
|
|
||||||
func allowHandshake(from peerID: String) -> Bool {
|
func allowHandshake(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneMinuteAgo = now.addingTimeInterval(-60)
|
let oneMinuteAgo = now.addingTimeInterval(-60)
|
||||||
@@ -175,7 +169,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func allowMessage(from peerID: String) -> Bool {
|
func allowMessage(from peerID: PeerID) -> Bool {
|
||||||
return queue.sync(flags: .barrier) {
|
return queue.sync(flags: .barrier) {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
let oneSecondAgo = now.addingTimeInterval(-1)
|
let oneSecondAgo = now.addingTimeInterval(-1)
|
||||||
@@ -204,7 +198,7 @@ final class NoiseRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func reset(for peerID: String) {
|
func reset(for peerID: PeerID) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.handshakeTimestamps.removeValue(forKey: peerID)
|
self.handshakeTimestamps.removeValue(forKey: peerID)
|
||||||
self.messageTimestamps.removeValue(forKey: peerID)
|
self.messageTimestamps.removeValue(forKey: peerID)
|
||||||
|
|||||||
@@ -10,32 +10,8 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
// MARK: - Noise Session State
|
|
||||||
|
|
||||||
enum NoiseSessionState: Equatable {
|
|
||||||
case uninitialized
|
|
||||||
case handshaking
|
|
||||||
case established
|
|
||||||
case failed(Error)
|
|
||||||
|
|
||||||
static func == (lhs: NoiseSessionState, rhs: NoiseSessionState) -> Bool {
|
|
||||||
switch (lhs, rhs) {
|
|
||||||
case (.uninitialized, .uninitialized),
|
|
||||||
(.handshaking, .handshaking),
|
|
||||||
(.established, .established):
|
|
||||||
return true
|
|
||||||
case (.failed, .failed):
|
|
||||||
return true // We don't compare the errors
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Session
|
|
||||||
|
|
||||||
class NoiseSession {
|
class NoiseSession {
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
let role: NoiseRole
|
let role: NoiseRole
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private var state: NoiseSessionState = .uninitialized
|
private var state: NoiseSessionState = .uninitialized
|
||||||
@@ -55,7 +31,7 @@ class NoiseSession {
|
|||||||
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
private let sessionQueue = DispatchQueue(label: "chat.bitchat.noise.session", attributes: .concurrent)
|
||||||
|
|
||||||
init(
|
init(
|
||||||
peerID: String,
|
peerID: PeerID,
|
||||||
role: NoiseRole,
|
role: NoiseRole,
|
||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
@@ -141,7 +117,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete (no response needed), transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
@@ -167,7 +143,7 @@ class NoiseSession {
|
|||||||
handshakeState = nil // Clear handshake state
|
handshakeState = nil // Clear handshake state
|
||||||
|
|
||||||
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
SecureLogger.debug("NoiseSession[\(peerID)]: Handshake complete after writing response, transitioning to established")
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return response
|
||||||
@@ -252,249 +228,8 @@ class NoiseSession {
|
|||||||
handshakeHash = nil
|
handshakeHash = nil
|
||||||
|
|
||||||
if wasEstablished {
|
if wasEstablished {
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Session Manager
|
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
|
||||||
private var sessions: [String: NoiseSession] = [:]
|
|
||||||
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
|
||||||
private let keychain: KeychainManagerProtocol
|
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
|
||||||
|
|
||||||
// Callbacks
|
|
||||||
var onSessionEstablished: ((String, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
|
||||||
var onSessionFailed: ((String, Error) -> Void)?
|
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
|
||||||
self.localStaticKey = localStaticKey
|
|
||||||
self.keychain = keychain
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Management
|
|
||||||
|
|
||||||
func createSession(for peerID: String, role: NoiseRole) -> NoiseSession {
|
|
||||||
return managerQueue.sync(flags: .barrier) {
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: role,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
return session
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getSession(for peerID: String) -> NoiseSession? {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions[peerID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeSession(for peerID: String) {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
if let session = sessions[peerID] {
|
|
||||||
if session.isEstablished() {
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
// Clear sensitive data before removing
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAllSessions() {
|
|
||||||
managerQueue.sync(flags: .barrier) {
|
|
||||||
for (_, session) in sessions {
|
|
||||||
session.reset()
|
|
||||||
}
|
|
||||||
sessions.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEstablishedSessions() -> [String: NoiseSession] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
return sessions.filter { $0.value.isEstablished() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Handshake Helpers
|
|
||||||
|
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
// Check if we already have an established session
|
|
||||||
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
|
||||||
// Session already established, don't recreate
|
|
||||||
throw NoiseSessionError.alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any existing non-established session
|
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new initiator session
|
|
||||||
let session = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .initiator,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = session
|
|
||||||
|
|
||||||
do {
|
|
||||||
let handshakeData = try session.startHandshake()
|
|
||||||
return handshakeData
|
|
||||||
} catch {
|
|
||||||
// Clean up failed session
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: String, message: Data) throws -> Data? {
|
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
|
||||||
var shouldCreateNew = false
|
|
||||||
var existingSession: NoiseSession? = nil
|
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
|
||||||
// If we have an established session, the peer must have cleared their session
|
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
|
||||||
// We should accept the new handshake to re-establish encryption
|
|
||||||
if existing.isEstablished() {
|
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
|
||||||
// reset and start fresh (the other side may have restarted)
|
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
shouldCreateNew = true
|
|
||||||
} else {
|
|
||||||
existingSession = existing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = SecureNoiseSession(
|
|
||||||
peerID: peerID,
|
|
||||||
role: .responder,
|
|
||||||
keychain: keychain,
|
|
||||||
localStaticKey: localStaticKey
|
|
||||||
)
|
|
||||||
sessions[peerID] = newSession
|
|
||||||
session = newSession
|
|
||||||
} else {
|
|
||||||
session = existingSession!
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
|
||||||
do {
|
|
||||||
let response = try session.processHandshakeMessage(message)
|
|
||||||
|
|
||||||
// Check if session is established after processing
|
|
||||||
if session.isEstablished() {
|
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
} catch {
|
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
|
||||||
DispatchQueue.global().async { [weak self] in
|
|
||||||
self?.onSessionFailed?(peerID, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID, error: error.localizedDescription))
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
|
||||||
|
|
||||||
func encrypt(_ plaintext: Data, for peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.encrypt(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decrypt(_ ciphertext: Data, from peerID: String) throws -> Data {
|
|
||||||
guard let session = getSession(for: peerID) else {
|
|
||||||
throw NoiseSessionError.sessionNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return try session.decrypt(ciphertext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Key Management
|
|
||||||
|
|
||||||
func getRemoteStaticKey(for peerID: String) -> Curve25519.KeyAgreement.PublicKey? {
|
|
||||||
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
|
||||||
}
|
|
||||||
|
|
||||||
func getHandshakeHash(for peerID: String) -> Data? {
|
|
||||||
return getSession(for: peerID)?.getHandshakeHash()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Rekeying
|
|
||||||
|
|
||||||
func getSessionsNeedingRekey() -> [(peerID: String, needsRekey: Bool)] {
|
|
||||||
return managerQueue.sync {
|
|
||||||
var needingRekey: [(peerID: String, needsRekey: Bool)] = []
|
|
||||||
|
|
||||||
for (peerID, session) in sessions {
|
|
||||||
if let secureSession = session as? SecureNoiseSession,
|
|
||||||
secureSession.isEstablished(),
|
|
||||||
secureSession.needsRenegotiation() {
|
|
||||||
needingRekey.append((peerID: peerID, needsRekey: true))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return needingRekey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func initiateRekey(for peerID: String) throws {
|
|
||||||
// Remove old session
|
|
||||||
removeSession(for: peerID)
|
|
||||||
|
|
||||||
// Initiate new handshake
|
|
||||||
_ = try initiateHandshake(with: peerID)
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Errors
|
|
||||||
|
|
||||||
enum NoiseSessionError: Error {
|
|
||||||
case invalidState
|
|
||||||
case notEstablished
|
|
||||||
case sessionNotFound
|
|
||||||
case handshakeFailed(Error)
|
|
||||||
case alreadyEstablished
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionError.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionError: Error {
|
||||||
|
case invalidState
|
||||||
|
case notEstablished
|
||||||
|
case sessionNotFound
|
||||||
|
case handshakeFailed(Error)
|
||||||
|
case alreadyEstablished
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionManager.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class NoiseSessionManager {
|
||||||
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey) -> Void)?
|
||||||
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
func createSession(for peerID: PeerID, role: NoiseRole) -> NoiseSession {
|
||||||
|
return managerQueue.sync(flags: .barrier) {
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: role,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSession(for peerID: PeerID) -> NoiseSession? {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions[peerID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeSession(for peerID: PeerID) {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
if let session = sessions[peerID] {
|
||||||
|
if session.isEstablished() {
|
||||||
|
SecureLogger.info(.sessionExpired(peerID: peerID.id))
|
||||||
|
}
|
||||||
|
// Clear sensitive data before removing
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAllSessions() {
|
||||||
|
managerQueue.sync(flags: .barrier) {
|
||||||
|
for (_, session) in sessions {
|
||||||
|
session.reset()
|
||||||
|
}
|
||||||
|
sessions.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEstablishedSessions() -> [PeerID: NoiseSession] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
return sessions.filter { $0.value.isEstablished() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Handshake Helpers
|
||||||
|
|
||||||
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
// Check if we already have an established session
|
||||||
|
if let existingSession = sessions[peerID], existingSession.isEstablished() {
|
||||||
|
// Session already established, don't recreate
|
||||||
|
throw NoiseSessionError.alreadyEstablished
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any existing non-established session
|
||||||
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new initiator session
|
||||||
|
let session = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .initiator,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = session
|
||||||
|
|
||||||
|
do {
|
||||||
|
let handshakeData = try session.startHandshake()
|
||||||
|
return handshakeData
|
||||||
|
} catch {
|
||||||
|
// Clean up failed session
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
|
var shouldCreateNew = false
|
||||||
|
var existingSession: NoiseSession? = nil
|
||||||
|
|
||||||
|
if let existing = sessions[peerID] {
|
||||||
|
// If we have an established session, the peer must have cleared their session
|
||||||
|
// for a good reason (e.g., decryption failure, restart, etc.)
|
||||||
|
// We should accept the new handshake to re-establish encryption
|
||||||
|
if existing.isEstablished() {
|
||||||
|
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
// If we're in the middle of a handshake and receive a new initiation,
|
||||||
|
// reset and start fresh (the other side may have restarted)
|
||||||
|
if existing.getState() == .handshaking && message.count == 32 {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
shouldCreateNew = true
|
||||||
|
} else {
|
||||||
|
existingSession = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
shouldCreateNew = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create session
|
||||||
|
let session: NoiseSession
|
||||||
|
if shouldCreateNew {
|
||||||
|
let newSession = SecureNoiseSession(
|
||||||
|
peerID: peerID,
|
||||||
|
role: .responder,
|
||||||
|
keychain: keychain,
|
||||||
|
localStaticKey: localStaticKey
|
||||||
|
)
|
||||||
|
sessions[peerID] = newSession
|
||||||
|
session = newSession
|
||||||
|
} else {
|
||||||
|
session = existingSession!
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the handshake message within the synchronized block
|
||||||
|
do {
|
||||||
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
|
// Check if session is established after processing
|
||||||
|
if session.isEstablished() {
|
||||||
|
if let remoteKey = session.getRemoteStaticPublicKey() {
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
} catch {
|
||||||
|
// Reset the session on handshake failure so next attempt can start fresh
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionFailed?(peerID, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
func encrypt(_ plaintext: Data, for peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.encrypt(plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decrypt(_ ciphertext: Data, from peerID: PeerID) throws -> Data {
|
||||||
|
guard let session = getSession(for: peerID) else {
|
||||||
|
throw NoiseSessionError.sessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return try session.decrypt(ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Key Management
|
||||||
|
|
||||||
|
func getRemoteStaticKey(for peerID: PeerID) -> Curve25519.KeyAgreement.PublicKey? {
|
||||||
|
return getSession(for: peerID)?.getRemoteStaticPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHandshakeHash(for peerID: PeerID) -> Data? {
|
||||||
|
return getSession(for: peerID)?.getHandshakeHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Rekeying
|
||||||
|
|
||||||
|
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)] {
|
||||||
|
return managerQueue.sync {
|
||||||
|
var needingRekey: [(peerID: PeerID, needsRekey: Bool)] = []
|
||||||
|
|
||||||
|
for (peerID, session) in sessions {
|
||||||
|
if let secureSession = session as? SecureNoiseSession,
|
||||||
|
secureSession.isEstablished(),
|
||||||
|
secureSession.needsRenegotiation() {
|
||||||
|
needingRekey.append((peerID: peerID, needsRekey: true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return needingRekey
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func initiateRekey(for peerID: PeerID) throws {
|
||||||
|
// Remove old session
|
||||||
|
removeSession(for: peerID)
|
||||||
|
|
||||||
|
// Initiate new handshake
|
||||||
|
_ = try initiateHandshake(with: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// NoiseSessionState.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
enum NoiseSessionState: Equatable {
|
||||||
|
case uninitialized
|
||||||
|
case handshaking
|
||||||
|
case established
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ struct NostrEmbeddedBitChat {
|
|||||||
if let maybeData = Data(hexString: recipientPeerID) {
|
if let maybeData = Data(hexString: recipientPeerID) {
|
||||||
if maybeData.count == 32 {
|
if maybeData.count == 32 {
|
||||||
// Treat as Noise static public key; derive peerID from fingerprint
|
// Treat as Noise static public key; derive peerID from fingerprint
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: maybeData)
|
return PeerID(publicKey: maybeData).id
|
||||||
} else if maybeData.count == 8 {
|
} else if maybeData.count == 8 {
|
||||||
// Already an 8-byte peer ID
|
// Already an 8-byte peer ID
|
||||||
return recipientPeerID
|
return recipientPeerID
|
||||||
|
|||||||
@@ -226,10 +226,7 @@ struct NostrIdentityBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// As a final fallback, hash the seed+msg and try again
|
// As a final fallback, hash the seed+msg and try again
|
||||||
var combined = Data()
|
let fallback = (seed + msg).sha256Hash()
|
||||||
combined.append(seed)
|
|
||||||
combined.append(msg)
|
|
||||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
|
||||||
return try NostrIdentity(privateKeyData: fallback)
|
return try NostrIdentity(privateKeyData: fallback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,10 +521,7 @@ struct NostrEvent: Codable {
|
|||||||
] as [Any]
|
] as [Any]
|
||||||
|
|
||||||
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
let data = try JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||||
let hash = CryptoKit.SHA256.hash(data: data)
|
return (data.sha256Fingerprint(), data.sha256Hash())
|
||||||
let hashData = Data(hash)
|
|
||||||
let hashHex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
|
||||||
return (hashHex, hashData)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func jsonString() throws -> String {
|
func jsonString() throws -> String {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Manages WebSocket connections to Nostr relays
|
/// Manages WebSocket connections to Nostr relays
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -41,6 +42,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
|
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
|
private var hasMutualFavorites: Bool = false
|
||||||
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: URLSessionWebSocketTask] = [:]
|
private var connections: [String: URLSessionWebSocketTask] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
||||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||||
@@ -82,17 +85,27 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var connectionGeneration: Int = 0
|
private var connectionGeneration: Int = 0
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty
|
||||||
allowDefaultRelays = hasMutual
|
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized
|
||||||
if hasMutual {
|
applyDefaultRelayPolicy(force: true)
|
||||||
self.relays = Self.defaultRelays.map { Relay(url: $0) }
|
|
||||||
}
|
|
||||||
// Deterministic JSON shape for outbound requests
|
// Deterministic JSON shape for outbound requests
|
||||||
self.encoder.outputFormatting = .sortedKeys
|
self.encoder.outputFormatting = .sortedKeys
|
||||||
FavoritesPersistenceService.shared.$mutualFavorites
|
FavoritesPersistenceService.shared.$mutualFavorites
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] favorites in
|
.sink { [weak self] favorites in
|
||||||
self?.updateDefaultRelayPolicy(hasMutual: !favorites.isEmpty)
|
guard let self = self else { return }
|
||||||
|
self.hasMutualFavorites = !favorites.isEmpty
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
LocationChannelManager.shared.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] state in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let authorized = (state == .authorized)
|
||||||
|
if authorized == self.hasLocationPermission { return }
|
||||||
|
self.hasLocationPermission = authorized
|
||||||
|
self.applyDefaultRelayPolicy()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
@@ -326,10 +339,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateDefaultRelayPolicy(hasMutual: Bool) {
|
private func applyDefaultRelayPolicy(force: Bool = false) {
|
||||||
guard hasMutual != allowDefaultRelays else { return }
|
let shouldAllow = hasMutualFavorites || hasLocationPermission
|
||||||
allowDefaultRelays = hasMutual
|
if !force && shouldAllow == allowDefaultRelays { return }
|
||||||
if hasMutual {
|
allowDefaultRelays = shouldAllow
|
||||||
|
if shouldAllow {
|
||||||
var existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in Self.defaultRelays where !existing.contains(url) {
|
for url in Self.defaultRelays where !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ extension Data {
|
|||||||
offset += 16
|
offset += 16
|
||||||
|
|
||||||
// Convert 16 bytes to UUID string format
|
// Convert 16 bytes to UUID string format
|
||||||
let uuid = uuidData.map { String(format: "%02x", $0) }.joined()
|
let uuid = uuidData.hexEncodedString()
|
||||||
|
|
||||||
// Insert hyphens at proper positions: 8-4-4-4-12
|
// Insert hyphens at proper positions: 8-4-4-4-12
|
||||||
var result = ""
|
var result = ""
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ enum LazyHandshakeState {
|
|||||||
// MARK: - Delivery Status
|
// MARK: - Delivery Status
|
||||||
|
|
||||||
// Delivery status for messages
|
// Delivery status for messages
|
||||||
enum DeliveryStatus: Codable, Equatable {
|
enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||||
case sending
|
case sending
|
||||||
case sent // Left our device
|
case sent // Left our device
|
||||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||||
@@ -159,11 +160,12 @@ enum DeliveryStatus: Codable, Equatable {
|
|||||||
|
|
||||||
// MARK: - Delegate Protocol
|
// MARK: - Delegate Protocol
|
||||||
|
|
||||||
|
@MainActor
|
||||||
protocol BitchatDelegate: AnyObject {
|
protocol BitchatDelegate: AnyObject {
|
||||||
func didReceiveMessage(_ message: BitchatMessage)
|
func didReceiveMessage(_ message: BitchatMessage)
|
||||||
func didConnectToPeer(_ peerID: String)
|
func didConnectToPeer(_ peerID: PeerID)
|
||||||
func didDisconnectFromPeer(_ peerID: String)
|
func didDisconnectFromPeer(_ peerID: PeerID)
|
||||||
func didUpdatePeerList(_ peers: [String])
|
func didUpdatePeerList(_ peers: [PeerID])
|
||||||
|
|
||||||
// Optional method to check if a fingerprint belongs to a favorite peer
|
// Optional method to check if a fingerprint belongs to a favorite peer
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
@@ -171,11 +173,15 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
|
||||||
|
|
||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date)
|
|
||||||
|
// Bluetooth state updates for user notifications
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provide default implementation to make it effectively optional
|
// Provide default implementation to make it effectively optional
|
||||||
|
@MainActor
|
||||||
extension BitchatDelegate {
|
extension BitchatDelegate {
|
||||||
func isFavorite(fingerprint: String) -> Bool {
|
func isFavorite(fingerprint: String) -> Bool {
|
||||||
return false
|
return false
|
||||||
@@ -185,11 +191,11 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
/// Validates a geohash string for building-level precision (8 characters).
|
||||||
|
/// - Parameter geohash: The geohash string to validate
|
||||||
|
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||||
|
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||||
|
guard geohash.count == 8 else { return false }
|
||||||
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
|
}
|
||||||
|
|
||||||
/// Encodes the provided coordinates into a geohash string.
|
/// Encodes the provided coordinates into a geohash string.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - latitude: Latitude in degrees (-90...90)
|
/// - latitude: Latitude in degrees (-90...90)
|
||||||
|
|||||||
@@ -23,15 +23,21 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .building: return "Building"
|
case .building:
|
||||||
case .block: return "Block"
|
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||||
case .neighborhood: return "Neighborhood"
|
case .block:
|
||||||
case .city: return "City"
|
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||||
case .province: return "Province"
|
case .neighborhood:
|
||||||
case .region: return "Region"
|
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||||
|
case .city:
|
||||||
|
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||||
|
case .province:
|
||||||
|
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||||
|
case .region:
|
||||||
|
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Backward-compatible Codable for renamed cases
|
// Backward-compatible Codable for renamed cases
|
||||||
extension GeohashChannelLevel {
|
extension GeohashChannelLevel {
|
||||||
init(from decoder: Decoder) throws {
|
init(from decoder: Decoder) throws {
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// MARK: - Peer ID Utilities
|
|
||||||
|
|
||||||
struct PeerIDUtils {
|
|
||||||
/// Derive the stable 16-hex peer ID from a Noise static public key
|
|
||||||
static func derivePeerID(fromPublicKey publicKey: Data) -> String {
|
|
||||||
let digest = SHA256.hash(data: publicKey)
|
|
||||||
let hex = digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
return String(hex.prefix(16))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
+2077
-2020
File diff suppressed because it is too large
Load Diff
@@ -148,9 +148,9 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
if chatViewModel?.selectedPrivateChatPeer != nil {
|
if chatViewModel?.selectedPrivateChatPeer != nil {
|
||||||
// In private chat
|
// In private chat
|
||||||
if let peerNickname = meshService?.peerNickname(peerID: targetPeerID) {
|
if let peerNickname = meshService?.peerNickname(peerID: PeerID(str: targetPeerID)) {
|
||||||
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
let personalMessage = "* \(emoji) \(myNickname) \(action) you\(suffix) *"
|
||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: PeerID(str: targetPeerID),
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
messageID: UUID().uuidString)
|
||||||
// Also add a local system message so the sender sees a natural-language confirmation
|
// Also add a local system message so the sender sees a natural-language confirmation
|
||||||
@@ -214,7 +214,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
if identityManager.isBlocked(fingerprint: fingerprint) {
|
if identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is already blocked")
|
return .success(message: "\(nickname) is already blocked")
|
||||||
}
|
}
|
||||||
@@ -258,7 +258,7 @@ final class CommandProcessor {
|
|||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
|
||||||
let fingerprint = meshService?.getFingerprint(for: peerID) {
|
let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
if !identityManager.isBlocked(fingerprint: fingerprint) {
|
||||||
return .success(message: "\(nickname) is not blocked")
|
return .success(message: "\(nickname) is not blocked")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,125 +179,15 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
func getFavoriteStatus(forPeerID peerID: PeerID) -> FavoriteRelationship? {
|
||||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||||
guard peerID.count == 16 else { return nil }
|
guard peerID.isShort else { return nil }
|
||||||
for (pubkey, rel) in favorites {
|
for (pubkey, rel) in favorites where PeerID(publicKey: pubkey) == peerID {
|
||||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
return rel
|
||||||
if derived == peerID { return rel }
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update Nostr public key for a peer
|
|
||||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: nostrPubkey,
|
|
||||||
peerNickname: existing.peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update nickname for an existing favorite
|
|
||||||
func updateNickname(for peerNoisePublicKey: Data, newNickname: String) {
|
|
||||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
|
||||||
|
|
||||||
// Skip if nickname hasn't changed
|
|
||||||
if existing.peerNickname == newNickname { return }
|
|
||||||
|
|
||||||
// Updating nickname for favorite
|
|
||||||
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: existing.peerNoisePublicKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: newNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[peerNoisePublicKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: ["peerPublicKey": peerNoisePublicKey]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update noise public key when peer reconnects with new ID
|
|
||||||
func updateNoisePublicKey(from oldKey: Data, to newKey: Data, peerNickname: String) {
|
|
||||||
guard let existing = favorites[oldKey] else {
|
|
||||||
SecureLogger.warning("⚠️ Cannot update noise key - no favorite found for \(oldKey.hexEncodedString())", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we already have a favorite with the new key
|
|
||||||
if favorites[newKey] != nil {
|
|
||||||
SecureLogger.warning("⚠️ Favorite already exists with new key \(newKey.hexEncodedString()), removing old entry", category: .session)
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
saveFavorites()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Updating noise public key
|
|
||||||
|
|
||||||
// Remove old entry
|
|
||||||
favorites.removeValue(forKey: oldKey)
|
|
||||||
|
|
||||||
// Add with new key
|
|
||||||
let updated = FavoriteRelationship(
|
|
||||||
peerNoisePublicKey: newKey,
|
|
||||||
peerNostrPublicKey: existing.peerNostrPublicKey,
|
|
||||||
peerNickname: peerNickname,
|
|
||||||
isFavorite: existing.isFavorite,
|
|
||||||
theyFavoritedUs: existing.theyFavoritedUs,
|
|
||||||
favoritedAt: existing.favoritedAt,
|
|
||||||
lastUpdated: Date()
|
|
||||||
)
|
|
||||||
|
|
||||||
favorites[newKey] = updated
|
|
||||||
saveFavorites()
|
|
||||||
|
|
||||||
// Notify observers with both old and new keys
|
|
||||||
NotificationCenter.default.post(
|
|
||||||
name: .favoriteStatusChanged,
|
|
||||||
object: nil,
|
|
||||||
userInfo: [
|
|
||||||
"peerPublicKey": newKey,
|
|
||||||
"oldPeerPublicKey": oldKey,
|
|
||||||
"isKeyUpdate": true
|
|
||||||
]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorites (including non-mutual)
|
|
||||||
func getAllFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get only mutual favorites
|
|
||||||
func getMutualFavorites() -> [FavoriteRelationship] {
|
|
||||||
favorites.values.filter { $0.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all favorite relationships (including where they favorited us)
|
|
||||||
func getAllRelationships() -> [FavoriteRelationship] {
|
|
||||||
Array(favorites.values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear all favorites - used for panic mode
|
/// Clear all favorites - used for panic mode
|
||||||
func clearAllFavorites() {
|
func clearAllFavorites() {
|
||||||
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
SecureLogger.warning("🧹 Clearing all favorites (panic mode)", category: .session)
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
private var resolving: Set<String> = []
|
private var resolving: Set<String> = []
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private init() {
|
private let storage: UserDefaults
|
||||||
|
|
||||||
|
init(storage: UserDefaults = .standard) {
|
||||||
|
self.storage = storage
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
private func load() {
|
private func load() {
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
guard let data = storage.data(forKey: storeKey) else { return }
|
||||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
@@ -81,7 +84,7 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
membership = seen
|
membership = seen
|
||||||
}
|
}
|
||||||
// Load any saved names
|
// Load any saved names
|
||||||
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
|
if let namesData = storage.data(forKey: namesStoreKey),
|
||||||
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
|
||||||
bookmarkNames = dict
|
bookmarkNames = dict
|
||||||
}
|
}
|
||||||
@@ -89,13 +92,13 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
|
|
||||||
private func persist() {
|
private func persist() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||||
UserDefaults.standard.set(data, forKey: storeKey)
|
storage.set(data, forKey: storeKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistNames() {
|
private func persistNames() {
|
||||||
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
if let data = try? JSONEncoder().encode(bookmarkNames) {
|
||||||
UserDefaults.standard.set(data, forKey: namesStoreKey)
|
storage.set(data, forKey: namesStoreKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import BitLogger
|
||||||
import Foundation
|
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).
|
/// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars).
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesCounter: ObservableObject {
|
final class LocationNotesCounter: ObservableObject {
|
||||||
@@ -8,29 +37,50 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
@Published private(set) var geohash: String? = nil
|
@Published private(set) var geohash: String? = nil
|
||||||
@Published private(set) var count: Int? = 0
|
@Published private(set) var count: Int? = 0
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var relayAvailable: Bool = true
|
||||||
|
|
||||||
private var subscriptionID: String? = nil
|
private var subscriptionID: String? = nil
|
||||||
private var noteIDs = Set<String>()
|
private var noteIDs = Set<String>()
|
||||||
|
private let dependencies: LocationNotesCounterDependencies
|
||||||
|
|
||||||
private init() {}
|
private init(dependencies: LocationNotesCounterDependencies = .live) {
|
||||||
|
self.dependencies = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
init(testDependencies: LocationNotesCounterDependencies) {
|
||||||
|
self.dependencies = testDependencies
|
||||||
|
}
|
||||||
|
|
||||||
func subscribe(geohash gh: String) {
|
func subscribe(geohash gh: String) {
|
||||||
let norm = gh.lowercased()
|
let norm = gh.lowercased()
|
||||||
if geohash == norm, subscriptionID != nil { return }
|
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
|
// Unsubscribe previous without clearing count to avoid flicker
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
initialLoadComplete = false
|
initialLoadComplete = false
|
||||||
|
relayAvailable = true
|
||||||
|
|
||||||
// Subscribe only to the building geohash (precision 8)
|
// Subscribe only to the building geohash (precision 8)
|
||||||
let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))"
|
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
|
subscriptionID = subID
|
||||||
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500)
|
let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 200)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue 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 }
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return }
|
||||||
@@ -38,16 +88,17 @@ final class LocationNotesCounter: ObservableObject {
|
|||||||
self.noteIDs.insert(event.id)
|
self.noteIDs.insert(event.id)
|
||||||
self.count = self.noteIDs.count
|
self.count = self.noteIDs.count
|
||||||
}
|
}
|
||||||
}, onEOSE: { [weak self] in
|
}, { [weak self] in
|
||||||
self?.initialLoadComplete = true
|
self?.initialLoadComplete = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) }
|
if let sub = subscriptionID { dependencies.unsubscribe(sub) }
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
geohash = nil
|
geohash = nil
|
||||||
count = 0
|
count = 0
|
||||||
noteIDs.removeAll()
|
noteIDs.removeAll()
|
||||||
|
relayAvailable = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,57 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7).
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
|
struct LocationNotesDependencies {
|
||||||
|
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
|
||||||
|
typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void
|
||||||
|
|
||||||
|
var relayLookup: RelayLookup
|
||||||
|
var subscribe: Subscribe
|
||||||
|
var unsubscribe: Unsubscribe
|
||||||
|
var sendEvent: SendEvent
|
||||||
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
|
var now: () -> Date
|
||||||
|
|
||||||
|
static let live = LocationNotesDependencies(
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
sendEvent: { event, relays in
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||||
|
},
|
||||||
|
deriveIdentity: { geohash in
|
||||||
|
try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
||||||
|
},
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8).
|
||||||
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
/// Subscribes to and publishes notes for a given geohash and provides a send API.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class LocationNotesManager: ObservableObject {
|
final class LocationNotesManager: ObservableObject {
|
||||||
|
enum State: Equatable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case ready
|
||||||
|
case noRelays
|
||||||
|
}
|
||||||
|
|
||||||
struct Note: Identifiable, Equatable {
|
struct Note: Identifiable, Equatable {
|
||||||
let id: String
|
let id: String
|
||||||
let pubkey: String
|
let pubkey: String
|
||||||
@@ -24,46 +71,119 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
@Published private(set) var notes: [Note] = [] // reverse-chron sorted
|
||||||
@Published private(set) var geohash: String
|
@Published private(set) var geohash: String
|
||||||
@Published private(set) var initialLoadComplete: Bool = false
|
@Published private(set) var initialLoadComplete: Bool = false
|
||||||
|
@Published private(set) var state: State = .loading
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
|
private let dependencies: LocationNotesDependencies
|
||||||
|
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
||||||
|
|
||||||
init(geohash: String) {
|
private enum Strings {
|
||||||
self.geohash = geohash.lowercased()
|
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||||
|
|
||||||
|
static func failedToSend(_ detail: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||||
|
locale: .current,
|
||||||
|
detail
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(geohash: String, dependencies: LocationNotesDependencies = .live) {
|
||||||
|
let norm = geohash.lowercased()
|
||||||
|
self.geohash = norm
|
||||||
|
self.dependencies = dependencies
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
if !Geohash.isValidBuildingGeohash(norm) {
|
||||||
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
}
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setGeohash(_ newGeohash: String) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
let norm = newGeohash.lowercased()
|
let norm = newGeohash.lowercased()
|
||||||
guard norm != geohash else { return }
|
guard norm != geohash else { return }
|
||||||
|
// Validate geohash (building-level precision: 8 chars)
|
||||||
|
guard Geohash.isValidBuildingGeohash(norm) else {
|
||||||
|
SecureLogger.warning("LocationNotesManager: rejecting invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
geohash = norm
|
geohash = norm
|
||||||
notes.removeAll()
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
subscribe()
|
subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
|
// Set loading state before clearing to prevent empty state flicker
|
||||||
|
state = .loading
|
||||||
|
initialLoadComplete = false
|
||||||
|
errorMessage = nil
|
||||||
|
notes.removeAll()
|
||||||
|
noteIDs.removeAll()
|
||||||
|
subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() {
|
||||||
|
errorMessage = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func subscribe() {
|
private func subscribe() {
|
||||||
|
state = .loading
|
||||||
|
errorMessage = nil
|
||||||
|
if let sub = subscriptionID {
|
||||||
|
dependencies.unsubscribe(sub)
|
||||||
|
subscriptionID = nil
|
||||||
|
}
|
||||||
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))"
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
subscriptionID = nil
|
||||||
|
initialLoadComplete = true
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
subscriptionID = subID
|
subscriptionID = subID
|
||||||
|
initialLoadComplete = false
|
||||||
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
// For persistent notes, allow relays to return recent history without an aggressive time cutoff
|
||||||
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
let relayUrls: [String]? = relays.isEmpty ? nil : relays
|
dependencies.subscribe(filter, subID, relays, { [weak self] event in
|
||||||
initialLoadComplete = false
|
|
||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in
|
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return }
|
||||||
// Ensure matching tag
|
// Ensure matching tag
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return }
|
||||||
if self.notes.contains(where: { $0.id == event.id }) { 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
|
let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first
|
||||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick)
|
||||||
self.notes.append(note)
|
self.notes.append(note)
|
||||||
self.notes.sort { $0.createdAt > $1.createdAt }
|
self.notes.sort { $0.createdAt > $1.createdAt }
|
||||||
}, onEOSE: { [weak self] in
|
self.enforceMemoryCap()
|
||||||
self?.initialLoadComplete = true
|
self.state = .ready
|
||||||
|
}, { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.initialLoadComplete = true
|
||||||
|
if self.state != .noRelays {
|
||||||
|
self.state = .ready
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,29 +191,57 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
func send(content: String, nickname: String) {
|
func send(content: String, nickname: String) {
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
|
guard !relays.isEmpty else {
|
||||||
|
state = .noRelays
|
||||||
|
errorMessage = Strings.noRelays
|
||||||
|
SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash)
|
let id = try dependencies.deriveIdentity(geohash)
|
||||||
let event = try NostrProtocol.createGeohashTextNote(
|
let event = try NostrProtocol.createGeohashTextNote(
|
||||||
content: trimmed,
|
content: trimmed,
|
||||||
geohash: geohash,
|
geohash: geohash,
|
||||||
senderIdentity: id,
|
senderIdentity: id,
|
||||||
nickname: nickname
|
nickname: nickname
|
||||||
)
|
)
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
dependencies.sendEvent(event, relays)
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
// Optimistic local-echo
|
// Optimistic local-echo
|
||||||
let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname)
|
let echo = Note(
|
||||||
|
id: event.id,
|
||||||
|
pubkey: id.publicKeyHex,
|
||||||
|
content: trimmed,
|
||||||
|
createdAt: Date(timeIntervalSince1970: TimeInterval(event.created_at)),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
self.noteIDs.insert(event.id)
|
||||||
self.notes.insert(echo, at: 0)
|
self.notes.insert(echo, at: 0)
|
||||||
|
self.enforceMemoryCap()
|
||||||
|
self.state = .ready
|
||||||
|
self.errorMessage = nil
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session)
|
||||||
|
errorMessage = Strings.failedToSend(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enforces defensive memory cap on notes array (keeps newest).
|
||||||
|
private func enforceMemoryCap() {
|
||||||
|
if notes.count > maxNotesInMemory {
|
||||||
|
let removed = notes.count - maxNotesInMemory
|
||||||
|
notes = Array(notes.prefix(maxNotesInMemory))
|
||||||
|
SecureLogger.debug("LocationNotesManager: trimmed \(removed) old notes (cap: \(maxNotesInMemory))", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Explicitly cancel subscription and release resources.
|
/// Explicitly cancel subscription and release resources.
|
||||||
func cancel() {
|
func cancel() {
|
||||||
if let sub = subscriptionID {
|
if let sub = subscriptionID {
|
||||||
NostrRelayManager.shared.unsubscribe(id: sub)
|
dependencies.unsubscribe(sub)
|
||||||
subscriptionID = nil
|
subscriptionID = nil
|
||||||
}
|
}
|
||||||
|
state = .idle
|
||||||
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Foundation
|
|||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let mesh: Transport
|
private let mesh: Transport
|
||||||
private let nostr: NostrTransport
|
private let nostr: NostrTransport
|
||||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||||
|
|
||||||
init(mesh: Transport, nostr: NostrTransport) {
|
init(mesh: Transport, nostr: NostrTransport) {
|
||||||
self.mesh = mesh
|
self.mesh = mesh
|
||||||
@@ -21,7 +21,7 @@ final class MessageRouter {
|
|||||||
) { [weak self] note in
|
) { [weak self] note in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
let peerID = PeerID(publicKey: data)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ final class MessageRouter {
|
|||||||
// Handle key updates
|
// Handle key updates
|
||||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
let peerID = PeerID(publicKey: newKey)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.flushOutbox(for: peerID)
|
self.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
@@ -37,44 +37,44 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let reachableMesh = mesh.isPeerReachable(peerID)
|
let reachableMesh = mesh.isPeerReachable(peerID)
|
||||||
if reachableMesh {
|
if reachableMesh {
|
||||||
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// BLEService will initiate a handshake if needed and queue the message
|
// BLEService will initiate a handshake if needed and queue the message
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing PM via Nostr to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||||
SecureLogger.debug("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
// Prefer mesh for reachable peers; BLE will queue if handshake is needed
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
mesh.sendReadReceipt(receipt, to: peerID)
|
mesh.sendReadReceipt(receipt, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via Nostr to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
nostr.sendReadReceipt(receipt, to: peerID)
|
nostr.sendReadReceipt(receipt, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing DELIVERED ack via mesh (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
} else {
|
} else {
|
||||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
// Route via mesh when connected; else use Nostr
|
// Route via mesh when connected; else use Nostr
|
||||||
if mesh.isPeerConnected(peerID) {
|
if mesh.isPeerConnected(peerID) {
|
||||||
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
mesh.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
@@ -84,16 +84,16 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
// MARK: - Outbox Management
|
||||||
private func canSendViaNostr(peerID: String) -> Bool {
|
private func canSendViaNostr(peerID: PeerID) -> Bool {
|
||||||
// Two forms are supported:
|
// Two forms are supported:
|
||||||
// - 64-hex Noise public key (32 bytes)
|
// - 64-hex Noise public key (32 bytes)
|
||||||
// - 16-hex short peer ID (derived from Noise pubkey)
|
// - 16-hex short peer ID (derived from Noise pubkey)
|
||||||
if peerID.count == 64, let noiseKey = Data(hexString: peerID) {
|
if let noiseKey = peerID.noiseKey {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
} else if peerID.count == 16 {
|
} else if peerID.isShort {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
fav.peerNostrPublicKey != nil {
|
fav.peerNostrPublicKey != nil {
|
||||||
return true
|
return true
|
||||||
@@ -102,17 +102,17 @@ final class MessageRouter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushOutbox(for peerID: String) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
var remaining: [(content: String, nickname: String, messageID: String)] = []
|
||||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||||
for (content, nickname, messageID) in queued {
|
for (content, nickname, messageID) in queued {
|
||||||
if mesh.isPeerReachable(peerID) {
|
if mesh.isPeerReachable(peerID) {
|
||||||
SecureLogger.debug("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> mesh for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else if canSendViaNostr(peerID: peerID) {
|
} else if canSendViaNostr(peerID: peerID) {
|
||||||
SecureLogger.debug("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> Nostr for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||||
} else {
|
} else {
|
||||||
// Keep unsent items queued
|
// Keep unsent items queued
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import Combine
|
import Combine
|
||||||
|
import Tor
|
||||||
|
|
||||||
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
|
||||||
/// Policy: permit start when either location permissions are authorized OR
|
/// Policy: permit start when either location permissions are authorized OR
|
||||||
|
|||||||
@@ -62,7 +62,6 @@
|
|||||||
/// ## Integration Points
|
/// ## Integration Points
|
||||||
/// - **BLEService**: Calls this service for all private messages
|
/// - **BLEService**: Calls this service for all private messages
|
||||||
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
/// - **ChatViewModel**: Monitors encryption status for UI indicators
|
||||||
/// - **NoiseHandshakeCoordinator**: Prevents handshake race conditions
|
|
||||||
/// - **KeychainManager**: Secure storage for identity keys
|
/// - **KeychainManager**: Secure storage for identity keys
|
||||||
///
|
///
|
||||||
/// ## Thread Safety
|
/// ## Thread Safety
|
||||||
@@ -116,15 +115,30 @@ enum EncryptionStatus: Equatable {
|
|||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .none:
|
case .none:
|
||||||
return "Encryption failed"
|
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||||
case .noHandshake:
|
case .noHandshake:
|
||||||
return "Not encrypted"
|
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||||
case .noiseHandshaking:
|
case .noiseHandshaking:
|
||||||
return "Establishing encryption..."
|
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||||
case .noiseSecured:
|
case .noiseSecured:
|
||||||
return "Encrypted"
|
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||||
case .noiseVerified:
|
case .noiseVerified:
|
||||||
return "Encrypted & Verified"
|
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var accessibilityDescription: String {
|
||||||
|
switch self {
|
||||||
|
case .none:
|
||||||
|
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||||
|
case .noHandshake:
|
||||||
|
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||||
|
case .noiseHandshaking:
|
||||||
|
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||||
|
case .noiseSecured:
|
||||||
|
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||||
|
case .noiseVerified:
|
||||||
|
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,8 +162,8 @@ final class NoiseEncryptionService {
|
|||||||
private let sessionManager: NoiseSessionManager
|
private let sessionManager: NoiseSessionManager
|
||||||
|
|
||||||
// Peer fingerprints (SHA256 hash of static public key)
|
// Peer fingerprints (SHA256 hash of static public key)
|
||||||
private var peerFingerprints: [String: String] = [:] // peerID -> fingerprint
|
private var peerFingerprints: [PeerID: String] = [:]
|
||||||
private var fingerprintToPeerID: [String: String] = [:] // fingerprint -> peerID
|
private var fingerprintToPeerID: [String: PeerID] = [:]
|
||||||
|
|
||||||
// Thread safety
|
// Thread safety
|
||||||
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
private let serviceQueue = DispatchQueue(label: "chat.bitchat.noise.service", attributes: .concurrent)
|
||||||
@@ -164,7 +178,7 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
private var onPeerAuthenticatedHandlers: [((String, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||||
var onHandshakeRequired: ((String) -> Void)? // peerID needs handshake
|
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||||
|
|
||||||
// Add a handler for peer authentication
|
// Add a handler for peer authentication
|
||||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
func addOnPeerAuthenticatedHandler(_ handler: @escaping (String, String) -> Void) {
|
||||||
@@ -258,12 +272,11 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Get our identity fingerprint
|
/// Get our identity fingerprint
|
||||||
func getIdentityFingerprint() -> String {
|
func getIdentityFingerprint() -> String {
|
||||||
let hash = SHA256.hash(data: staticIdentityPublicKey.rawRepresentation)
|
staticIdentityPublicKey.rawRepresentation.sha256Fingerprint()
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer's public key data
|
/// Get peer's public key data
|
||||||
func getPeerPublicKeyData(_ peerID: String) -> Data? {
|
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,11 +404,11 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Handshake Management
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
func initiateHandshake(with peerID: String) throws -> Data {
|
func initiateHandshake(with peerID: PeerID) throws -> Data {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +418,7 @@ final class NoiseEncryptionService {
|
|||||||
throw NoiseSecurityError.rateLimitExceeded
|
throw NoiseSecurityError.rateLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.info(.handshakeStarted(peerID: peerID))
|
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||||
|
|
||||||
// Return raw handshake data without wrapper
|
// Return raw handshake data without wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
@@ -414,17 +427,17 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: String, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard NoiseSecurityValidator.validatePeerID(peerID) else {
|
guard peerID.isValid else {
|
||||||
SecureLogger.warning(.authenticationFailed(peerID: peerID))
|
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||||
throw NoiseSecurityError.invalidPeerID
|
throw NoiseSecurityError.invalidPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
guard NoiseSecurityValidator.validateHandshakeMessageSize(message) else {
|
||||||
SecureLogger.warning(.handshakeFailed(peerID: peerID, error: "Message too large"))
|
SecureLogger.warning(.handshakeFailed(peerID: peerID.id, error: "Message too large"))
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,19 +457,19 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
func hasEstablishedSession(with peerID: String) -> Bool {
|
func hasEstablishedSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
return sessionManager.getSession(for: peerID)?.isEstablished() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have a session (established or handshaking) with a peer
|
/// Check if we have a session (established or handshaking) with a peer
|
||||||
func hasSession(with peerID: String) -> Bool {
|
func hasSession(with peerID: PeerID) -> Bool {
|
||||||
return sessionManager.getSession(for: peerID) != nil
|
return sessionManager.getSession(for: peerID) != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
/// Encrypt data for a specific peer
|
/// Encrypt data for a specific peer
|
||||||
func encrypt(_ data: Data, for peerID: String) throws -> Data {
|
func encrypt(_ data: Data, for peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -478,7 +491,7 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt data from a specific peer
|
/// Decrypt data from a specific peer
|
||||||
func decrypt(_ data: Data, from peerID: String) throws -> Data {
|
func decrypt(_ data: Data, from peerID: PeerID) throws -> Data {
|
||||||
// Validate message size
|
// Validate message size
|
||||||
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
guard NoiseSecurityValidator.validateMessageSize(data) else {
|
||||||
throw NoiseSecurityError.messageTooLarge
|
throw NoiseSecurityError.messageTooLarge
|
||||||
@@ -500,33 +513,12 @@ final class NoiseEncryptionService {
|
|||||||
// MARK: - Peer Management
|
// MARK: - Peer Management
|
||||||
|
|
||||||
/// Get fingerprint for a peer
|
/// Get fingerprint for a peer
|
||||||
func getPeerFingerprint(_ peerID: String) -> String? {
|
func getPeerFingerprint(_ peerID: PeerID) -> String? {
|
||||||
return serviceQueue.sync {
|
return serviceQueue.sync {
|
||||||
return peerFingerprints[peerID]
|
return peerFingerprints[peerID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for a fingerprint
|
|
||||||
func getPeerID(for fingerprint: String) -> String? {
|
|
||||||
return serviceQueue.sync {
|
|
||||||
return fingerprintToPeerID[fingerprint]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a peer session
|
|
||||||
func removePeer(_ peerID: String) {
|
|
||||||
sessionManager.removeSession(for: peerID)
|
|
||||||
|
|
||||||
serviceQueue.sync(flags: .barrier) {
|
|
||||||
if let fingerprint = peerFingerprints[peerID] {
|
|
||||||
fingerprintToPeerID.removeValue(forKey: fingerprint)
|
|
||||||
}
|
|
||||||
peerFingerprints.removeValue(forKey: peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.info(.sessionExpired(peerID: peerID))
|
|
||||||
}
|
|
||||||
|
|
||||||
func clearEphemeralStateForPanic() {
|
func clearEphemeralStateForPanic() {
|
||||||
sessionManager.removeAllSessions()
|
sessionManager.removeAllSessions()
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -538,9 +530,9 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
private func handleSessionEstablished(peerID: String, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
|
||||||
// Calculate fingerprint
|
// Calculate fingerprint
|
||||||
let fingerprint = calculateFingerprint(for: remoteStaticKey)
|
let fingerprint = remoteStaticKey.rawRepresentation.sha256Fingerprint()
|
||||||
|
|
||||||
// Store fingerprint mapping
|
// Store fingerprint mapping
|
||||||
serviceQueue.sync(flags: .barrier) {
|
serviceQueue.sync(flags: .barrier) {
|
||||||
@@ -549,21 +541,16 @@ final class NoiseEncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log security event
|
// Log security event
|
||||||
SecureLogger.info(.handshakeCompleted(peerID: peerID))
|
SecureLogger.info(.handshakeCompleted(peerID: peerID.id))
|
||||||
|
|
||||||
// Notify all handlers about authentication
|
// Notify all handlers about authentication
|
||||||
serviceQueue.async { [weak self] in
|
serviceQueue.async { [weak self] in
|
||||||
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
self?.onPeerAuthenticatedHandlers.forEach { handler in
|
||||||
handler(peerID, fingerprint)
|
handler(peerID.id, fingerprint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
|
|
||||||
let hash = SHA256.hash(data: publicKey.rawRepresentation)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Session Maintenance
|
// MARK: - Session Maintenance
|
||||||
|
|
||||||
private func startRekeyTimer() {
|
private func startRekeyTimer() {
|
||||||
|
|||||||
@@ -4,53 +4,57 @@ import Combine
|
|||||||
|
|
||||||
// Minimal Nostr transport conforming to Transport for offline sending
|
// Minimal Nostr transport conforming to Transport for offline sending
|
||||||
final class NostrTransport: Transport {
|
final class NostrTransport: Transport {
|
||||||
weak var delegate: BitchatDelegate?
|
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
|
||||||
Just([]).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
|
||||||
|
|
||||||
// Provide BLE short peer ID for BitChat embedding
|
// Provide BLE short peer ID for BitChat embedding
|
||||||
var senderPeerID: String = ""
|
var senderPeerID = PeerID(str: "")
|
||||||
|
|
||||||
// Throttle READ receipts to avoid relay rate limits
|
// Throttle READ receipts to avoid relay rate limits
|
||||||
private struct QueuedRead {
|
private struct QueuedRead {
|
||||||
let receipt: ReadReceipt
|
let receipt: ReadReceipt
|
||||||
let peerID: String
|
let peerID: PeerID
|
||||||
}
|
}
|
||||||
private var readQueue: [QueuedRead] = []
|
private var readQueue: [QueuedRead] = []
|
||||||
private var isSendingReadAcks = false
|
private var isSendingReadAcks = false
|
||||||
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
var myPeerID: String { senderPeerID }
|
|
||||||
var myNickname: String { "" }
|
|
||||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol) {
|
init(keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
|
Just([]).eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] { [] }
|
||||||
|
|
||||||
|
var myPeerID: PeerID { senderPeerID }
|
||||||
|
var myNickname: String { "" }
|
||||||
|
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||||
|
|
||||||
func startServices() { /* no-op */ }
|
func startServices() { /* no-op */ }
|
||||||
func stopServices() { /* no-op */ }
|
func stopServices() { /* no-op */ }
|
||||||
func emergencyDisconnectAll() { /* no-op */ }
|
func emergencyDisconnectAll() { /* no-op */ }
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool { false }
|
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||||
func isPeerReachable(_ peerID: String) -> Bool { false }
|
func isPeerReachable(_ peerID: PeerID) -> Bool { false }
|
||||||
func peerNickname(peerID: String) -> String? { nil }
|
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||||
func getPeerNicknames() -> [String : String] { [:] }
|
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||||
|
|
||||||
func getFingerprint(for peerID: String) -> String? { nil }
|
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: String) { /* no-op */ }
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
private static var cachedNoiseService: NoiseEncryptionService?
|
||||||
|
private static let noiseServiceLock = NSLock()
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
func getNoiseService() -> NoiseEncryptionService {
|
||||||
if let noiseService = Self.cachedNoiseService {
|
Self.noiseServiceLock.lock()
|
||||||
return noiseService
|
defer { Self.noiseServiceLock.unlock() }
|
||||||
}
|
if let noiseService = Self.cachedNoiseService { return noiseService }
|
||||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
Self.cachedNoiseService = noiseService
|
Self.cachedNoiseService = noiseService
|
||||||
return noiseService
|
return noiseService
|
||||||
@@ -59,11 +63,11 @@ final class NostrTransport: Transport {
|
|||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
// Convert recipient npub -> hex (x-only)
|
// Convert recipient npub -> hex (x-only)
|
||||||
let recipientHex: String
|
let recipientHex: String
|
||||||
do {
|
do {
|
||||||
@@ -77,7 +81,7 @@ final class NostrTransport: Transport {
|
|||||||
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -90,12 +94,113 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
processReadQueueIfNeeded()
|
processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
// Convert recipient npub -> hex
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||||
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
||||||
|
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||||
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
|
let recipientHex: String
|
||||||
|
do {
|
||||||
|
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||||
|
guard hrp == "npub" else { return }
|
||||||
|
recipientHex = data.hexEncodedString()
|
||||||
|
} catch { return }
|
||||||
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Geohash Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
|
|
||||||
|
// MARK: Geohash ACK helpers
|
||||||
|
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
Task { @MainActor in
|
||||||
|
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID.id) else { return }
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Geohash DMs (per-geohash identity)
|
||||||
|
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
Task { @MainActor in
|
||||||
|
guard !recipientHex.isEmpty else { return }
|
||||||
|
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
||||||
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID.id) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||||
|
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
||||||
|
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
extension NostrTransport {
|
||||||
private func processReadQueueIfNeeded() {
|
private func processReadQueueIfNeeded() {
|
||||||
guard !isSendingReadAcks else { return }
|
guard !isSendingReadAcks else { return }
|
||||||
guard !readQueue.isEmpty else { return }
|
guard !readQueue.isEmpty else { return }
|
||||||
@@ -117,7 +222,7 @@ final class NostrTransport: Transport {
|
|||||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||||
recipientHex = data.hexEncodedString()
|
recipientHex = data.hexEncodedString()
|
||||||
} catch { scheduleNextReadAck(); return }
|
} catch { scheduleNextReadAck(); return }
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID.id, senderPeerID: senderPeerID.id) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
scheduleNextReadAck(); return
|
scheduleNextReadAck(); return
|
||||||
}
|
}
|
||||||
@@ -139,111 +244,18 @@ final class NostrTransport: Transport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
// Convert recipient npub -> hex
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: String) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
|
|
||||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…", category: .session)
|
|
||||||
let recipientHex: String
|
|
||||||
do {
|
|
||||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
|
||||||
guard hrp == "npub" else { return }
|
|
||||||
recipientHex = data.hexEncodedString()
|
|
||||||
} catch { return }
|
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash ACK helpers
|
|
||||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
|
||||||
Task { @MainActor in
|
|
||||||
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Geohash DMs (per-geohash identity)
|
|
||||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
|
||||||
Task { @MainActor in
|
|
||||||
guard !recipientHex.isEmpty else { return }
|
|
||||||
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", category: .session)
|
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
|
||||||
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", category: .session)
|
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,26 +70,6 @@ final class NotificationService {
|
|||||||
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
sendLocalNotification(title: title, body: body, identifier: identifier, userInfo: userInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendFavoriteOnlineNotification(nickname: String) {
|
|
||||||
// Send directly without checking app state for favorites
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
let content = UNMutableNotificationContent()
|
|
||||||
content.title = "⭐ \(nickname) is online!"
|
|
||||||
content.body = "wanna get in there?"
|
|
||||||
content.sound = .default
|
|
||||||
|
|
||||||
let request = UNNotificationRequest(
|
|
||||||
identifier: "favorite-online-\(UUID().uuidString)",
|
|
||||||
content: content,
|
|
||||||
trigger: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request) { _ in
|
|
||||||
// Notification added
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Geohash public chat notification with deep link to a specific geohash
|
// Geohash public chat notification with deep link to a specific geohash
|
||||||
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
func sendGeohashActivityNotification(geohash: String, titlePrefix: String = "#", bodyPreview: String) {
|
||||||
let title = "\(titlePrefix)\(geohash)"
|
let title = "\(titlePrefix)\(geohash)"
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//
|
||||||
|
// NotificationStreamAssembler.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NotificationStreamAssembler {
|
||||||
|
private var buffer = Data()
|
||||||
|
|
||||||
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
|
buffer.append(chunk)
|
||||||
|
|
||||||
|
var frames: [Data] = []
|
||||||
|
var dropped: [UInt8] = []
|
||||||
|
var reset = false
|
||||||
|
let maxFrameLength = TransportConfig.blePendingWriteBufferCapBytes
|
||||||
|
|
||||||
|
let minHeaderBytes = 14 // version + type + ttl + timestamp(8) + flags + length(2)
|
||||||
|
let minFramePrefix = minHeaderBytes + BinaryProtocol.senderIDSize
|
||||||
|
|
||||||
|
while buffer.count >= minFramePrefix {
|
||||||
|
guard let first = buffer.first else { break }
|
||||||
|
if first != 1 {
|
||||||
|
dropped.append(buffer.removeFirst())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard buffer.count >= minHeaderBytes else { break }
|
||||||
|
|
||||||
|
let headerBytes = Array(buffer.prefix(minFramePrefix))
|
||||||
|
guard headerBytes.count == minFramePrefix else { break }
|
||||||
|
|
||||||
|
let flags = headerBytes[11]
|
||||||
|
let hasRecipient = (flags & BinaryProtocol.Flags.hasRecipient) != 0
|
||||||
|
let hasSignature = (flags & BinaryProtocol.Flags.hasSignature) != 0
|
||||||
|
let payloadLen = (Int(headerBytes[12]) << 8) | Int(headerBytes[13])
|
||||||
|
|
||||||
|
var frameLength = minFramePrefix + payloadLen
|
||||||
|
if hasRecipient { frameLength += BinaryProtocol.recipientIDSize }
|
||||||
|
if hasSignature { frameLength += BinaryProtocol.signatureSize }
|
||||||
|
|
||||||
|
guard frameLength > 0, frameLength <= maxFrameLength else {
|
||||||
|
buffer.removeAll()
|
||||||
|
reset = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if buffer.count < frameLength {
|
||||||
|
// Check if a new frame start exists within the incomplete buffer; if so, drop leading partial bytes.
|
||||||
|
if let nextStart = buffer.dropFirst().firstIndex(of: 1) {
|
||||||
|
let dropCount = buffer.distance(from: buffer.startIndex, to: nextStart)
|
||||||
|
if dropCount > 0 {
|
||||||
|
buffer.removeFirst(dropCount)
|
||||||
|
dropped.append(1) // treat as dropped partial start
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
|
frames.append(frame)
|
||||||
|
buffer.removeFirst(frameLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (frames, dropped, reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reset() {
|
||||||
|
buffer.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|
||||||
// Store fingerprint for persistence across reconnections
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: PeerID(str: peerID)) {
|
||||||
selectedPeerFingerprint = fingerprint
|
selectedPeerFingerprint = fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,103 +54,27 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a private message
|
|
||||||
func sendMessage(_ content: String, to peerID: String) {
|
|
||||||
guard let meshService = meshService,
|
|
||||||
let peerNickname = meshService.peerNickname(peerID: peerID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let messageID = UUID().uuidString
|
|
||||||
|
|
||||||
// Create local message
|
|
||||||
let message = BitchatMessage(
|
|
||||||
id: messageID,
|
|
||||||
sender: meshService.myNickname,
|
|
||||||
content: content,
|
|
||||||
timestamp: Date(),
|
|
||||||
isRelay: false,
|
|
||||||
originalSender: nil,
|
|
||||||
isPrivate: true,
|
|
||||||
recipientNickname: peerNickname,
|
|
||||||
senderPeer: Peer(str: meshService.myPeerID),
|
|
||||||
mentions: nil,
|
|
||||||
deliveryStatus: .sending
|
|
||||||
)
|
|
||||||
|
|
||||||
// Add to chat
|
|
||||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
|
||||||
privateChats[peerID]?.append(message)
|
|
||||||
// Enforce per-chat cap on local append
|
|
||||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[peerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send via mesh service
|
|
||||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle incoming private message
|
|
||||||
func handleIncomingMessage(_ message: BitchatMessage) {
|
|
||||||
guard let senderPeerID = message.senderPeer?.id else { return }
|
|
||||||
|
|
||||||
// Initialize chat if needed
|
|
||||||
if privateChats[senderPeerID] == nil {
|
|
||||||
privateChats[senderPeerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate by ID: replace existing message if present, else append
|
|
||||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
|
||||||
privateChats[senderPeerID]?[idx] = message
|
|
||||||
} else {
|
|
||||||
privateChats[senderPeerID]?.append(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
|
||||||
sanitizeChat(for: senderPeerID)
|
|
||||||
// Enforce cap after sanitize
|
|
||||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
|
||||||
let remove = arr.count - privateChatCap
|
|
||||||
arr.removeFirst(remove)
|
|
||||||
privateChats[senderPeerID] = arr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as unread if not in this chat
|
|
||||||
if selectedPeer != senderPeerID {
|
|
||||||
unreadMessages.insert(senderPeerID)
|
|
||||||
|
|
||||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
|
||||||
if !sentReadReceipts.contains(message.id) {
|
|
||||||
NotificationService.shared.sendPrivateMessageNotification(
|
|
||||||
from: message.sender,
|
|
||||||
message: message.content,
|
|
||||||
peerID: senderPeerID
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Send read receipt if viewing this chat
|
|
||||||
sendReadReceipt(for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// Remove duplicate messages by ID and keep chronological order
|
||||||
func sanitizeChat(for peerID: String) {
|
func sanitizeChat(for peerID: String) {
|
||||||
guard let arr = privateChats[peerID] else { return }
|
guard let arr = privateChats[peerID] else { return }
|
||||||
var seen = Set<String>()
|
if arr.count <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexByID: [String: Int] = [:]
|
||||||
|
indexByID.reserveCapacity(arr.count)
|
||||||
var deduped: [BitchatMessage] = []
|
var deduped: [BitchatMessage] = []
|
||||||
|
deduped.reserveCapacity(arr.count)
|
||||||
|
|
||||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||||
if !seen.contains(msg.id) {
|
if let existing = indexByID[msg.id] {
|
||||||
seen.insert(msg.id)
|
deduped[existing] = msg
|
||||||
deduped.append(msg)
|
|
||||||
} else {
|
} else {
|
||||||
// Replace previous with the latest occurrence (which is later in sort)
|
indexByID[msg.id] = deduped.count
|
||||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
deduped.append(msg)
|
||||||
deduped[index] = msg
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID] = deduped
|
privateChats[peerID] = deduped
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,60 +85,18 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
if let messages = privateChats[peerID] {
|
||||||
for message in messages {
|
for message in messages {
|
||||||
if message.senderPeer?.id == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
sendReadReceipt(for: message)
|
sendReadReceipt(for: message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the selected peer if fingerprint matches (for reconnections)
|
|
||||||
func updateSelectedPeer(peers: [String: String]) {
|
|
||||||
guard let fingerprint = selectedPeerFingerprint else { return }
|
|
||||||
|
|
||||||
// Find peer with matching fingerprint
|
|
||||||
for (peerID, _) in peers {
|
|
||||||
if meshService?.getFingerprint(for: peerID) == fingerprint {
|
|
||||||
selectedPeer = peerID
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get chat messages for current context
|
|
||||||
func getCurrentMessages() -> [BitchatMessage] {
|
|
||||||
guard let peer = selectedPeer else { return [] }
|
|
||||||
return privateChats[peer] ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Clear a private chat
|
|
||||||
func clearChat(with peerID: String) {
|
|
||||||
privateChats[peerID]?.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle delivery acknowledgment
|
|
||||||
func handleDeliveryAck(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .delivered(to: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle read receipt
|
|
||||||
func handleReadReceipt(messageID: String, from peerID: String) {
|
|
||||||
guard privateChats[peerID] != nil else { return }
|
|
||||||
|
|
||||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
|
||||||
privateChats[peerID]?[index].deliveryStatus = .read(by: "recipient", at: Date())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private func sendReadReceipt(for message: BitchatMessage) {
|
private func sendReadReceipt(for message: BitchatMessage) {
|
||||||
guard !sentReadReceipts.contains(message.id),
|
guard !sentReadReceipts.contains(message.id),
|
||||||
let senderPeerID = message.senderPeer?.id else {
|
let senderPeerID = message.senderPeerID else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,13 +105,13 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Create read receipt using the simplified method
|
// Create read receipt using the simplified method
|
||||||
let receipt = ReadReceipt(
|
let receipt = ReadReceipt(
|
||||||
originalMessageID: message.id,
|
originalMessageID: message.id,
|
||||||
readerID: meshService?.myPeerID ?? "",
|
readerID: meshService?.myPeerID.id ?? "",
|
||||||
readerNickname: meshService?.myNickname ?? ""
|
readerNickname: meshService?.myNickname ?? ""
|
||||||
)
|
)
|
||||||
|
|
||||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||||
if let router = messageRouter {
|
if let router = messageRouter {
|
||||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session)
|
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,13 +18,17 @@ struct RelayController {
|
|||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
|
if ttlCap <= 1 || senderIsSelf {
|
||||||
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
|
}
|
||||||
|
|
||||||
// For session-critical or directed traffic, be deterministic and reliable
|
// For session-critical or directed traffic, be deterministic and reliable
|
||||||
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
if isHandshake || isDirectedFragment || isDirectedEncrypted {
|
||||||
// Always relay with no TTL cap for these types
|
// Always relay with no TTL cap for these types
|
||||||
let newTTL = (ttl &- 1)
|
let newTTL = ttlCap &- 1
|
||||||
// Slight jitter to desynchronize without adding too much latency
|
// Slight jitter to desynchronize without adding too much latency
|
||||||
// Tighter for faster multi-hop handshakes and directed DMs
|
// Tighter for faster multi-hop handshakes and directed DMs
|
||||||
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
let delayRange: ClosedRange<Int> = isHandshake ? 10...35 : 20...60
|
||||||
@@ -32,28 +36,17 @@ struct RelayController {
|
|||||||
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
|
|
||||||
let baseProb: Double
|
|
||||||
switch degree {
|
|
||||||
case 0...2: baseProb = 1.0
|
|
||||||
case 3...4: baseProb = 0.9
|
|
||||||
case 5...6: baseProb = 0.7
|
|
||||||
case 7...9: baseProb = 0.55
|
|
||||||
default: baseProb = 0.45
|
|
||||||
}
|
|
||||||
let prob = baseProb
|
|
||||||
let shouldRelay = Double.random(in: 0...1) <= prob
|
|
||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep very low to avoid floods
|
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||||
// - Sparse graphs: allow slightly longer reach for multi-hop discovery
|
// - Announces get a bit more headroom
|
||||||
// - Announces in sparse graphs get a bit more headroom
|
let ttlLimit: UInt8 = {
|
||||||
let ttlCap: UInt8 = {
|
if degree >= highDegreeThreshold {
|
||||||
if degree >= highDegreeThreshold { return 3 }
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
return isAnnounce ? 7 : 6
|
}
|
||||||
|
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||||
|
return max(UInt8(2), min(ttlCap, preferred))
|
||||||
}()
|
}()
|
||||||
let clamped = max(1, min(ttl, ttlCap))
|
let newTTL = ttlLimit &- 1
|
||||||
let newTTL = clamped &- 1
|
|
||||||
|
|
||||||
// Wider jitter window to allow duplicate suppression to win more often
|
// Wider jitter window to allow duplicate suppression to win more often
|
||||||
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
// For sparse graphs (<=2), relay quickly to avoid cancellation races
|
||||||
@@ -64,6 +57,6 @@ struct RelayController {
|
|||||||
case 6...9: delayMs = Int.random(in: 80...180)
|
case 6...9: delayMs = Int.random(in: 80...180)
|
||||||
default: delayMs = Int.random(in: 100...220)
|
default: delayMs = Int.random(in: 100...220)
|
||||||
}
|
}
|
||||||
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
|
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Combine
|
|||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
struct TransportPeerSnapshot: Equatable, Hashable {
|
struct TransportPeerSnapshot: Equatable, Hashable {
|
||||||
let id: String
|
let peerID: PeerID
|
||||||
let nickname: String
|
let nickname: String
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
@@ -12,13 +12,17 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Peer events (preferred over publishers for UI)
|
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
// Peer events (preferred over publishers for UI)
|
||||||
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
|
// Peer snapshots (for non-UI services)
|
||||||
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
||||||
|
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
||||||
|
|
||||||
// Identity
|
// Identity
|
||||||
var myPeerID: String { get }
|
var myPeerID: PeerID { get }
|
||||||
var myNickname: String { get }
|
var myNickname: String { get }
|
||||||
func setNickname(_ nickname: String)
|
func setNickname(_ nickname: String)
|
||||||
|
|
||||||
@@ -28,37 +32,33 @@ protocol Transport: AnyObject {
|
|||||||
func emergencyDisconnectAll()
|
func emergencyDisconnectAll()
|
||||||
|
|
||||||
// Connectivity and peers
|
// Connectivity and peers
|
||||||
func isPeerConnected(_ peerID: String) -> Bool
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
func isPeerReachable(_ peerID: String) -> Bool
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||||
func peerNickname(peerID: String) -> String?
|
func peerNickname(peerID: PeerID) -> String?
|
||||||
func getPeerNicknames() -> [String: String]
|
func getPeerNicknames() -> [PeerID: String]
|
||||||
|
|
||||||
// Protocol utilities
|
// Protocol utilities
|
||||||
func getFingerprint(for peerID: String) -> String?
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
func getNoiseSessionState(for peerID: String) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: String)
|
func triggerHandshake(with peerID: PeerID)
|
||||||
func getNoiseService() -> NoiseEncryptionService
|
func getNoiseService() -> NoiseEncryptionService
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String)
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool)
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
func sendBroadcastAnnounce()
|
func sendBroadcastAnnounce()
|
||||||
func sendDeliveryAck(for messageID: String, to peerID: String)
|
func sendDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
|
||||||
// QR verification (optional for transports)
|
// QR verification (optional for transports)
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||||
|
|
||||||
// Peer snapshots (for non-UI services)
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol TransportPeerEventsDelegate: AnyObject {
|
protocol TransportPeerEventsDelegate: AnyObject {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import BitLogger
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
/// Single source of truth for peer state, combining mesh connectivity and favorites
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -19,14 +18,14 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
|
|
||||||
@Published private(set) var peers: [BitchatPeer] = []
|
@Published private(set) var peers: [BitchatPeer] = []
|
||||||
@Published private(set) var connectedPeerIDs: Set<String> = []
|
@Published private(set) var connectedPeerIDs: Set<PeerID> = []
|
||||||
@Published private(set) var favorites: [BitchatPeer] = []
|
@Published private(set) var favorites: [BitchatPeer] = []
|
||||||
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
@Published private(set) var mutualFavorites: [BitchatPeer] = []
|
||||||
|
|
||||||
// MARK: - Private Properties
|
// MARK: - Private Properties
|
||||||
|
|
||||||
private var peerIndex: [String: BitchatPeer] = [:]
|
private var peerIndex: [PeerID: BitchatPeer] = [:]
|
||||||
private var fingerprintCache: [String: String] = [:] // peerID -> fingerprint
|
private var fingerprintCache: [PeerID: String] = [:]
|
||||||
private let meshService: Transport
|
private let meshService: Transport
|
||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
@@ -78,12 +77,12 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let favorites = favoritesService.favorites
|
let favorites = favoritesService.favorites
|
||||||
|
|
||||||
var enrichedPeers: [BitchatPeer] = []
|
var enrichedPeers: [BitchatPeer] = []
|
||||||
var connected: Set<String> = []
|
var connected: Set<PeerID> = []
|
||||||
var addedPeerIDs: Set<String> = []
|
var addedPeerIDs: Set<PeerID> = []
|
||||||
|
|
||||||
// Phase 1: Add all mesh peers (connected and reachable)
|
// Phase 1: Add all mesh peers (connected and reachable)
|
||||||
for peerInfo in meshPeers {
|
for peerInfo in meshPeers {
|
||||||
let peerID = peerInfo.id
|
let peerID = peerInfo.peerID
|
||||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||||
|
|
||||||
let peer = buildPeerFromMesh(
|
let peer = buildPeerFromMesh(
|
||||||
@@ -104,7 +103,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
// Phase 2: Add offline favorites that we actively favorite
|
// Phase 2: Add offline favorites that we actively favorite
|
||||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||||
let peerID = favoriteKey.hexEncodedString()
|
let peerID = PeerID(hexData: favoriteKey)
|
||||||
|
|
||||||
// Skip if already added (connected peer)
|
// Skip if already added (connected peer)
|
||||||
if addedPeerIDs.contains(peerID) { continue }
|
if addedPeerIDs.contains(peerID) { continue }
|
||||||
@@ -138,10 +137,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// Phase 4: Build subsets and indices
|
// Phase 4: Build subsets and indices
|
||||||
var favoritesList: [BitchatPeer] = []
|
var favoritesList: [BitchatPeer] = []
|
||||||
var mutualsList: [BitchatPeer] = []
|
var mutualsList: [BitchatPeer] = []
|
||||||
var newIndex: [String: BitchatPeer] = [:]
|
var newIndex: [PeerID: BitchatPeer] = [:]
|
||||||
|
|
||||||
for peer in enrichedPeers {
|
for peer in enrichedPeers {
|
||||||
newIndex[peer.id] = peer
|
newIndex[peer.peerID] = peer
|
||||||
|
|
||||||
if peer.isFavorite {
|
if peer.isFavorite {
|
||||||
favoritesList.append(peer)
|
favoritesList.append(peer)
|
||||||
@@ -185,7 +184,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
let isReachable = peerInfo.isConnected ? true : (withinRetention && meshAttached)
|
||||||
|
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerInfo.id,
|
peerID: peerInfo.peerID,
|
||||||
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
noisePublicKey: peerInfo.noisePublicKey ?? Data(),
|
||||||
nickname: peerInfo.nickname,
|
nickname: peerInfo.nickname,
|
||||||
lastSeen: peerInfo.lastSeen,
|
lastSeen: peerInfo.lastSeen,
|
||||||
@@ -205,10 +204,10 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
|
|
||||||
private func buildPeerFromFavorite(
|
private func buildPeerFromFavorite(
|
||||||
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
favorite: FavoritesPersistenceService.FavoriteRelationship,
|
||||||
peerID: String
|
peerID: PeerID
|
||||||
) -> BitchatPeer {
|
) -> BitchatPeer {
|
||||||
var peer = BitchatPeer(
|
var peer = BitchatPeer(
|
||||||
id: peerID,
|
peerID: peerID,
|
||||||
noisePublicKey: favorite.peerNoisePublicKey,
|
noisePublicKey: favorite.peerNoisePublicKey,
|
||||||
nickname: favorite.peerNickname,
|
nickname: favorite.peerNickname,
|
||||||
lastSeen: favorite.lastUpdated,
|
lastSeen: favorite.lastUpdated,
|
||||||
@@ -225,27 +224,22 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Public Methods
|
// MARK: - Public Methods
|
||||||
|
|
||||||
/// Get peer by ID
|
/// Get peer by ID
|
||||||
func getPeer(by id: String) -> BitchatPeer? {
|
func getPeer(by peerID: PeerID) -> BitchatPeer? {
|
||||||
return peerIndex[id]
|
return peerIndex[peerID]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get peer ID for nickname
|
/// Get peer ID for nickname
|
||||||
func getPeerID(for nickname: String) -> String? {
|
func getPeerID(for nickname: String) -> String? {
|
||||||
for peer in peers {
|
for peer in peers {
|
||||||
if peer.displayName == nickname || peer.nickname == nickname {
|
if peer.displayName == nickname || peer.nickname == nickname {
|
||||||
return peer.id
|
return peer.peerID.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if peer is online
|
|
||||||
func isOnline(_ peerID: String) -> Bool {
|
|
||||||
return connectedPeerIDs.contains(peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if peer is blocked
|
/// Check if peer is blocked
|
||||||
func isBlocked(_ peerID: String) -> Bool {
|
func isBlocked(_ peerID: PeerID) -> Bool {
|
||||||
// Get fingerprint
|
// Get fingerprint
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
guard let fingerprint = getFingerprint(for: peerID) else { return false }
|
||||||
|
|
||||||
@@ -258,7 +252,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle favorite status
|
/// Toggle favorite status
|
||||||
func toggleFavorite(_ peerID: String) {
|
func toggleFavorite(_ peerID: PeerID) {
|
||||||
guard let peer = getPeer(by: peerID) else {
|
guard let peer = getPeer(by: peerID) else {
|
||||||
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
SecureLogger.warning("⚠️ Cannot toggle favorite - peer not found: \(peerID)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -322,39 +316,7 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle blocked status
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
func toggleBlocked(_ peerID: String) {
|
|
||||||
guard let fingerprint = getFingerprint(for: peerID) else { return }
|
|
||||||
|
|
||||||
// Get or create social identity
|
|
||||||
var identity = identityManager.getSocialIdentity(for: fingerprint)
|
|
||||||
?? SocialIdentity(
|
|
||||||
fingerprint: fingerprint,
|
|
||||||
localPetname: nil,
|
|
||||||
claimedNickname: getPeer(by: peerID)?.displayName ?? "Unknown",
|
|
||||||
trustLevel: .unknown,
|
|
||||||
isFavorite: false,
|
|
||||||
isBlocked: false,
|
|
||||||
notes: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
// Toggle blocked status
|
|
||||||
identity.isBlocked = !identity.isBlocked
|
|
||||||
|
|
||||||
// Can't be both favorite and blocked
|
|
||||||
if identity.isBlocked {
|
|
||||||
identity.isFavorite = false
|
|
||||||
// Also remove from favorites service
|
|
||||||
if let peer = getPeer(by: peerID) {
|
|
||||||
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identityManager.updateSocialIdentity(identity)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get fingerprint for peer ID
|
|
||||||
func getFingerprint(for peerID: String) -> String? {
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
if let cached = fingerprintCache[peerID] {
|
if let cached = fingerprintCache[peerID] {
|
||||||
return cached
|
return cached
|
||||||
@@ -379,23 +341,13 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
|||||||
// MARK: - Compatibility Methods (for easy migration)
|
// MARK: - Compatibility Methods (for easy migration)
|
||||||
|
|
||||||
var allPeers: [BitchatPeer] { peers }
|
var allPeers: [BitchatPeer] { peers }
|
||||||
var connectedPeers: [String] { Array(connectedPeerIDs) }
|
var connectedPeers: [PeerID] { Array(connectedPeerIDs) }
|
||||||
var favoritePeers: Set<String> {
|
var favoritePeers: Set<String> {
|
||||||
Set(favorites.compactMap { getFingerprint(for: $0.id) })
|
Set(favorites.compactMap { getFingerprint(for: $0.peerID) })
|
||||||
}
|
}
|
||||||
var blockedUsers: Set<String> {
|
var blockedUsers: Set<String> {
|
||||||
Set(peers.compactMap { peer in
|
Set(peers.compactMap { peer in
|
||||||
isBlocked(peer.id) ? getFingerprint(for: peer.id) : nil
|
isBlocked(peer.peerID) ? getFingerprint(for: peer.peerID) : nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Extensions
|
|
||||||
|
|
||||||
extension Data {
|
|
||||||
func sha256Fingerprint() -> String {
|
|
||||||
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
|
||||||
let hash = SHA256.hash(data: self)
|
|
||||||
return hash.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
|
||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
@@ -95,7 +94,7 @@ final class VerificationService {
|
|||||||
nickname: payload.nickname,
|
nickname: payload.nickname,
|
||||||
ts: payload.ts,
|
ts: payload.ts,
|
||||||
nonceB64: payload.nonceB64,
|
nonceB64: payload.nonceB64,
|
||||||
sigHex: sig.map { String(format: "%02x", $0) }.joined())
|
sigHex: sig.hexEncodedString())
|
||||||
let out = signed.toURLString()
|
let out = signed.toURLString()
|
||||||
Cache.last = (nickname, npub, Date(), out)
|
Cache.last = (nickname, npub, Date(), out)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import CryptoKit
|
|||||||
// Golomb-Coded Set (GCS) filter utilities for sync.
|
// Golomb-Coded Set (GCS) filter utilities for sync.
|
||||||
// Hashing:
|
// Hashing:
|
||||||
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
// - Packet ID is 16 bytes (see PacketIdUtil). For GCS mapping, use h64 = first 8 bytes of SHA-256 over the 16-byte ID.
|
||||||
// - Map to [0, M) via (h64 % M).
|
// - Map to [1, M) by computing (h64 % M) and remapping 0 -> 1 to avoid zero-length deltas.
|
||||||
// Encoding (v1):
|
// Encoding (v1):
|
||||||
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
// - Sort mapped values ascending; encode deltas (first is v0, then vi - v{i-1}) as positive integers x >= 1.
|
||||||
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||||
@@ -29,25 +29,40 @@ enum GCSFilter {
|
|||||||
|
|
||||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||||
let p = deriveP(targetFpr: targetFpr)
|
let p = deriveP(targetFpr: targetFpr)
|
||||||
|
guard !ids.isEmpty else {
|
||||||
|
return Params(p: p, m: 1, data: Data())
|
||||||
|
}
|
||||||
|
|
||||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||||
let n = min(ids.count, cap)
|
let selected = Array(ids.prefix(cap))
|
||||||
let selected = Array(ids.prefix(n))
|
let range = max(1, hashRange(count: selected.count, p: p))
|
||||||
// Map to [0, M)
|
let modulo = UInt64(range)
|
||||||
let mInit = UInt32(n << p)
|
|
||||||
var mapped = selected.map { id16 -> UInt64 in
|
var mapped = selected
|
||||||
let h = h64(id16)
|
.map { h64($0) }
|
||||||
return UInt64(h % UInt64(max(1, mInit)))
|
.map { mapHash($0, modulo: modulo) }
|
||||||
}.sorted()
|
.sorted()
|
||||||
|
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||||
|
|
||||||
|
if mapped.isEmpty {
|
||||||
|
return Params(p: p, m: range, data: Data())
|
||||||
|
}
|
||||||
|
|
||||||
var encoded = encode(sorted: mapped, p: p)
|
var encoded = encode(sorted: mapped, p: p)
|
||||||
var trimmedN = n
|
var trimmedCount = mapped.count
|
||||||
// Trim if over budget
|
|
||||||
while encoded.count > maxBytes && trimmedN > 0 {
|
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||||
trimmedN = (trimmedN * 9) / 10 // drop ~10%
|
if trimmedCount == 1 {
|
||||||
mapped = Array(mapped.prefix(trimmedN))
|
mapped.removeAll()
|
||||||
|
encoded = Data()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||||
|
mapped = Array(mapped.prefix(trimmedCount))
|
||||||
encoded = encode(sorted: mapped, p: p)
|
encoded = encode(sorted: mapped, p: p)
|
||||||
}
|
}
|
||||||
let finalM = UInt32(max(1, trimmedN << p))
|
|
||||||
return Params(p: p, m: finalM, data: encoded)
|
return Params(p: p, m: range, data: encoded)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||||
@@ -77,6 +92,12 @@ enum GCSFilter {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func bucket(for id: Data, modulus m: UInt32) -> UInt64 {
|
||||||
|
let modulo = UInt64(max(1, m))
|
||||||
|
guard modulo > 1 else { return 0 }
|
||||||
|
return mapHash(h64(id), modulo: modulo)
|
||||||
|
}
|
||||||
|
|
||||||
private static func h64(_ id16: Data) -> UInt64 {
|
private static func h64(_ id16: Data) -> UInt64 {
|
||||||
var hasher = SHA256()
|
var hasher = SHA256()
|
||||||
hasher.update(data: id16)
|
hasher.update(data: id16)
|
||||||
@@ -88,6 +109,39 @@ enum GCSFilter {
|
|||||||
return x & 0x7fff_ffff_ffff_ffff
|
return x & 0x7fff_ffff_ffff_ffff
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func hashRange(count: Int, p: Int) -> UInt32 {
|
||||||
|
guard count > 0 else { return 1 }
|
||||||
|
if p >= 64 { return UInt32.max }
|
||||||
|
let multiplier = UInt64(1) << UInt64(p)
|
||||||
|
let (product, overflow) = UInt64(count).multipliedReportingOverflow(by: multiplier)
|
||||||
|
if overflow { return UInt32.max }
|
||||||
|
if product == 0 { return 1 }
|
||||||
|
return product > UInt64(UInt32.max) ? UInt32.max : UInt32(product)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapHash(_ hash: UInt64, modulo: UInt64) -> UInt64 {
|
||||||
|
guard modulo > 1 else { return 0 }
|
||||||
|
let value = hash % modulo
|
||||||
|
if value == 0 { return 1 }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeMappedValues(_ values: [UInt64], modulo: UInt64) -> [UInt64] {
|
||||||
|
guard modulo > 1 else { return [] }
|
||||||
|
guard !values.isEmpty else { return [] }
|
||||||
|
var result: [UInt64] = []
|
||||||
|
result.reserveCapacity(values.count)
|
||||||
|
var last: UInt64 = 0
|
||||||
|
for value in values {
|
||||||
|
let normalized = min(value, modulo - 1)
|
||||||
|
if normalized > last {
|
||||||
|
result.append(normalized)
|
||||||
|
last = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
private static func encode(sorted: [UInt64], p: Int) -> Data {
|
||||||
let writer = BitWriter()
|
let writer = BitWriter()
|
||||||
var prev: UInt64 = 0
|
var prev: UInt64 = 0
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
|
||||||
|
|
||||||
// Gossip-based sync manager using on-demand GCS filters
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
protocol Delegate: AnyObject {
|
protocol Delegate: AnyObject {
|
||||||
func sendPacket(_ packet: BitchatPacket)
|
func sendPacket(_ packet: BitchatPacket)
|
||||||
func sendPacket(to peerID: String, packet: BitchatPacket)
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
|
||||||
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,9 +12,10 @@ final class GossipSyncManager {
|
|||||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: String
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ final class GossipSyncManager {
|
|||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
|
|
||||||
init(myPeerID: String, config: Config = Config()) {
|
init(myPeerID: PeerID, config: Config = Config()) {
|
||||||
self.myPeerID = myPeerID
|
self.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,10 @@ final class GossipSyncManager {
|
|||||||
stop()
|
stop()
|
||||||
let timer = DispatchSource.makeTimerSource(queue: queue)
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
||||||
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
timer.schedule(deadline: .now() + 30.0, repeating: 30.0, leeway: .seconds(1))
|
||||||
timer.setEventHandler { [weak self] in self?.sendRequestSync() }
|
timer.setEventHandler { [weak self] in
|
||||||
|
self?.cleanupExpiredMessages()
|
||||||
|
self?.sendRequestSync()
|
||||||
|
}
|
||||||
timer.resume()
|
timer.resume()
|
||||||
periodicTimer = timer
|
periodicTimer = timer
|
||||||
}
|
}
|
||||||
@@ -46,13 +49,31 @@ final class GossipSyncManager {
|
|||||||
periodicTimer?.cancel(); periodicTimer = nil
|
periodicTimer?.cancel(); periodicTimer = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleInitialSyncToPeer(_ peerID: String, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
self?.sendRequestSync(to: peerID)
|
self?.sendRequestSync(to: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
func onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._onPublicPacketSeen(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to check if a packet is within the age threshold
|
||||||
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||||
|
|
||||||
|
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||||
|
guard nowMs >= ageThresholdMs else { return true }
|
||||||
|
|
||||||
|
let cutoffMs = nowMs - ageThresholdMs
|
||||||
|
return packet.timestamp >= cutoffMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _onPublicPacketSeen(_ packet: BitchatPacket) {
|
||||||
let mt = MessageType(rawValue: packet.type)
|
let mt = MessageType(rawValue: packet.type)
|
||||||
let isBroadcastRecipient: Bool = {
|
let isBroadcastRecipient: Bool = {
|
||||||
guard let r = packet.recipientID else { return true }
|
guard let r = packet.recipientID else { return true }
|
||||||
@@ -62,6 +83,9 @@ final class GossipSyncManager {
|
|||||||
let isAnnounce = (mt == .announce)
|
let isAnnounce = (mt == .announce)
|
||||||
guard isBroadcastMessage || isAnnounce else { return }
|
guard isBroadcastMessage || isAnnounce else { return }
|
||||||
|
|
||||||
|
// Reject expired packets to prevent ghost peers and old messages
|
||||||
|
guard isPacketFresh(packet) else { return }
|
||||||
|
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
|
||||||
if isBroadcastMessage {
|
if isBroadcastMessage {
|
||||||
@@ -85,7 +109,7 @@ final class GossipSyncManager {
|
|||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: nil, // broadcast
|
recipientID: nil, // broadcast
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -96,10 +120,10 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(signed)
|
delegate?.sendPacket(signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendRequestSync(to peerID: String) {
|
private func sendRequestSync(to peerID: PeerID) {
|
||||||
let payload = buildGcsPayload()
|
let payload = buildGcsPayload()
|
||||||
var recipient = Data()
|
var recipient = Data()
|
||||||
var temp = peerID
|
var temp = peerID.id
|
||||||
while temp.count >= 2 && recipient.count < 8 {
|
while temp.count >= 2 && recipient.count < 8 {
|
||||||
let hexByte = String(temp.prefix(2))
|
let hexByte = String(temp.prefix(2))
|
||||||
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
if let b = UInt8(hexByte, radix: 16) { recipient.append(b) }
|
||||||
@@ -107,7 +131,7 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.requestSync.rawValue,
|
type: MessageType.requestSync.rawValue,
|
||||||
senderID: Data(hexString: myPeerID) ?? Data(),
|
senderID: Data(hexString: myPeerID.id) ?? Data(),
|
||||||
recipientID: recipient,
|
recipientID: recipient,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -118,51 +142,60 @@ final class GossipSyncManager {
|
|||||||
delegate?.sendPacket(to: peerID, packet: signed)
|
delegate?.sendPacket(to: peerID, packet: signed)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRequestSync(fromPeerID: String, request: RequestSyncPacket) {
|
func handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?._handleRequestSync(from: peerID, request: request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||||
// Decode GCS into sorted set and prepare membership checker
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
var hasher = SHA256()
|
let bucket = GCSFilter.bucket(for: id, modulus: request.m)
|
||||||
hasher.update(data: id) // 16-byte PacketId
|
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||||
let digest = hasher.finalize()
|
|
||||||
let db = Data(digest)
|
|
||||||
var x: UInt64 = 0
|
|
||||||
let take = min(8, db.count)
|
|
||||||
for i in 0..<take { x = (x << 8) | UInt64(db[i]) }
|
|
||||||
let v = (x & 0x7fff_ffff_ffff_ffff) % UInt64(request.m)
|
|
||||||
return GCSFilter.contains(sortedValues: sorted, candidate: v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Announcements: send latest per peer if requester lacks them
|
// 1) Announcements: send latest per peer if requester lacks them (and not expired)
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = Data(hexString: idHex) ?? Data()
|
let idBytes = Data(hexString: idHex) ?? Data()
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Broadcast messages: send all missing
|
// 2) Broadcast messages: send all missing (and not expired)
|
||||||
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
let toSendMsgs = messageOrder.compactMap { messages[$0] }
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
|
guard isPacketFresh(pkt) else { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
toSend.ttl = 0
|
toSend.ttl = 0
|
||||||
delegate?.sendPacket(to: fromPeerID, packet: toSend)
|
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||||
private func buildGcsPayload() -> Data {
|
private func buildGcsPayload() -> Data {
|
||||||
// Collect candidates: latest announce per peer + broadcast messages
|
// Collect candidates: latest announce per peer + broadcast messages (only fresh)
|
||||||
var candidates: [BitchatPacket] = []
|
var candidates: [BitchatPacket] = []
|
||||||
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
candidates.reserveCapacity(latestAnnouncementByPeer.count + messageOrder.count)
|
||||||
for (_, pair) in latestAnnouncementByPeer { candidates.append(pair.packet) }
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
for id in messageOrder { if let p = messages[id] { candidates.append(p) } }
|
if isPacketFresh(pair.packet) {
|
||||||
|
candidates.append(pair.packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in messageOrder {
|
||||||
|
if let p = messages[id], isPacketFresh(p) {
|
||||||
|
candidates.append(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Sort by timestamp desc
|
// Sort by timestamp desc
|
||||||
candidates.sort { $0.timestamp > $1.timestamp }
|
candidates.sort { $0.timestamp > $1.timestamp }
|
||||||
|
|
||||||
@@ -180,9 +213,32 @@ final class GossipSyncManager {
|
|||||||
return req.encode()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Periodic cleanup of expired messages and announcements
|
||||||
|
private func cleanupExpiredMessages() {
|
||||||
|
// Remove expired announcements
|
||||||
|
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||||
|
isPacketFresh(pair.packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired messages
|
||||||
|
let expiredMessageIds = messages.compactMap { id, pkt in
|
||||||
|
isPacketFresh(pkt) ? nil : id
|
||||||
|
}
|
||||||
|
for id in expiredMessageIds {
|
||||||
|
messages.removeValue(forKey: id)
|
||||||
|
messageOrder.removeAll { $0 == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicit removal hook for LEAVE/stale peer
|
// Explicit removal hook for LEAVE/stale peer
|
||||||
func removeAnnouncementForPeer(_ peerID: String) {
|
func removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
let normalizedPeerID = peerID.lowercased()
|
queue.async { [weak self] in
|
||||||
|
self?._removeAnnouncementForPeer(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func _removeAnnouncementForPeer(_ peerID: PeerID) {
|
||||||
|
let normalizedPeerID = peerID.id.lowercased()
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
_ = latestAnnouncementByPeer.removeValue(forKey: normalizedPeerID)
|
||||||
|
|
||||||
// Remove messages from this peer
|
// Remove messages from this peer
|
||||||
|
|||||||
@@ -14,8 +14,4 @@ enum PacketIdUtil {
|
|||||||
let digest = hasher.finalize()
|
let digest = hasher.finalize()
|
||||||
return Data(digest.prefix(16))
|
return Data(digest.prefix(16))
|
||||||
}
|
}
|
||||||
|
|
||||||
static func computeIdHex(_ packet: BitchatPacket) -> String {
|
|
||||||
return computeId(packet).hexEncodedString()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//
|
||||||
|
// Data+SHA256.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// Created by Islam on 26/09/2025.
|
||||||
|
//
|
||||||
|
|
||||||
|
import struct Foundation.Data
|
||||||
|
import struct CryptoKit.SHA256
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
/// Returns the hex representation of SHA256 hash
|
||||||
|
func sha256Fingerprint() -> String {
|
||||||
|
// Implementation matches existing fingerprint generation in NoiseEncryptionService
|
||||||
|
sha256Hash().hexEncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the SHA256 hash wrapped in Data
|
||||||
|
func sha256Hash() -> Data {
|
||||||
|
Data(SHA256.hash(data: self))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Provides Dynamic Type aware font helpers that map existing fixed sizes onto
|
||||||
|
/// preferred text styles so the UI scales with user accessibility settings.
|
||||||
|
extension Font {
|
||||||
|
static func bitchatSystem(size: CGFloat, weight: Font.Weight = .regular, design: Font.Design = .default) -> Font {
|
||||||
|
let style = Font.TextStyle.bitchatPreferredStyle(for: size)
|
||||||
|
var font = Font.system(style, design: design)
|
||||||
|
if weight != .regular {
|
||||||
|
font = font.weight(weight)
|
||||||
|
}
|
||||||
|
return font
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Font.TextStyle {
|
||||||
|
static func bitchatPreferredStyle(for size: CGFloat) -> Font.TextStyle {
|
||||||
|
switch size {
|
||||||
|
case ..<11.5:
|
||||||
|
return .caption2
|
||||||
|
case ..<13.0:
|
||||||
|
return .caption
|
||||||
|
case ..<13.75:
|
||||||
|
return .footnote
|
||||||
|
case ..<15.5:
|
||||||
|
return .subheadline
|
||||||
|
case ..<17.5:
|
||||||
|
return .callout
|
||||||
|
case ..<19.5:
|
||||||
|
return .body
|
||||||
|
case ..<22.5:
|
||||||
|
return .title3
|
||||||
|
case ..<27.5:
|
||||||
|
return .title2
|
||||||
|
case ..<34.0:
|
||||||
|
return .title
|
||||||
|
default:
|
||||||
|
return .largeTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,35 +8,15 @@ struct InputValidator {
|
|||||||
|
|
||||||
struct Limits {
|
struct Limits {
|
||||||
static let maxNicknameLength = 50
|
static let maxNicknameLength = 50
|
||||||
static let maxMessageLength = 10_000
|
// BinaryProtocol caps payload length at UInt16.max (65_535). Leave headroom
|
||||||
static let maxReasonLength = 200
|
// for headers/padding by limiting user content to 60_000 bytes.
|
||||||
static let maxPeerIDLength = 64
|
static let maxMessageLength = 60_000
|
||||||
static let hexPeerIDLength = 16 // 8 bytes = 16 hex chars
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Peer ID Validation
|
|
||||||
|
|
||||||
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
|
|
||||||
static func validatePeerID(_ peerID: String) -> Bool {
|
|
||||||
// Accept short routing IDs (exact 16-hex)
|
|
||||||
if PeerIDResolver.isShortID(peerID) { return true }
|
|
||||||
// If length equals short-hex length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.hexPeerIDLength { return false }
|
|
||||||
// Accept full Noise key hex (exact 64-hex)
|
|
||||||
if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
|
|
||||||
// If length equals full key length but isn't valid hex, reject
|
|
||||||
if peerID.count == Limits.maxPeerIDLength { return false }
|
|
||||||
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
|
|
||||||
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
|
|
||||||
return !peerID.isEmpty &&
|
|
||||||
peerID.count < Limits.maxPeerIDLength &&
|
|
||||||
peerID.rangeOfCharacter(from: validCharset.inverted) == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - String Content Validation
|
// MARK: - String Content Validation
|
||||||
|
|
||||||
/// Validates and sanitizes user-provided strings (nicknames, messages)
|
/// Validates and sanitizes user-provided strings used in UI
|
||||||
static func validateUserString(_ string: String, maxLength: Int, allowNewlines: Bool = false) -> String? {
|
static func validateUserString(_ string: String, maxLength: Int) -> String? {
|
||||||
// Check empty
|
// Check empty
|
||||||
guard !string.isEmpty else { return nil }
|
guard !string.isEmpty else { return nil }
|
||||||
|
|
||||||
@@ -47,13 +27,8 @@ struct InputValidator {
|
|||||||
// Check length
|
// Check length
|
||||||
guard trimmed.count <= maxLength else { return nil }
|
guard trimmed.count <= maxLength else { return nil }
|
||||||
|
|
||||||
// Remove control characters except allowed ones
|
// Remove control characters
|
||||||
var allowedControlChars = CharacterSet()
|
let controlChars = CharacterSet.controlCharacters
|
||||||
if allowNewlines {
|
|
||||||
allowedControlChars.insert(charactersIn: "\n\r")
|
|
||||||
}
|
|
||||||
|
|
||||||
let controlChars = CharacterSet.controlCharacters.subtracting(allowedControlChars)
|
|
||||||
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
let cleaned = trimmed.components(separatedBy: controlChars).joined()
|
||||||
|
|
||||||
// Ensure valid UTF-8 (should already be, but double-check)
|
// Ensure valid UTF-8 (should already be, but double-check)
|
||||||
@@ -68,17 +43,7 @@ struct InputValidator {
|
|||||||
|
|
||||||
/// Validates nickname
|
/// Validates nickname
|
||||||
static func validateNickname(_ nickname: String) -> String? {
|
static func validateNickname(_ nickname: String) -> String? {
|
||||||
return validateUserString(nickname, maxLength: Limits.maxNicknameLength, allowNewlines: false)
|
return validateUserString(nickname, maxLength: Limits.maxNicknameLength)
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates message content
|
|
||||||
static func validateMessageContent(_ content: String) -> String? {
|
|
||||||
return validateUserString(content, maxLength: Limits.maxMessageLength, allowNewlines: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates error/reason strings
|
|
||||||
static func validateReasonString(_ reason: String) -> String? {
|
|
||||||
return validateUserString(reason, maxLength: Limits.maxReasonLength, allowNewlines: false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Protocol Field Validation
|
// MARK: - Protocol Field Validation
|
||||||
@@ -86,11 +51,6 @@ struct InputValidator {
|
|||||||
// Note: Message type validation is performed closer to decoding using
|
// Note: Message type validation is performed closer to decoding using
|
||||||
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
// MessageType/NoisePayloadType enums; keeping validator free of stale lists.
|
||||||
|
|
||||||
/// Validates hop count is reasonable
|
|
||||||
static func validateHopCount(_ hopCount: UInt8) -> Bool {
|
|
||||||
return hopCount <= 10 // Prevent excessive forwarding
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates timestamp is reasonable (not too far in past or future)
|
/// Validates timestamp is reasonable (not too far in past or future)
|
||||||
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
static func validateTimestamp(_ timestamp: Date) -> Bool {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
@@ -99,37 +59,4 @@ struct InputValidator {
|
|||||||
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
return timestamp >= oneHourAgo && timestamp <= oneHourFromNow
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validates data size for different contexts
|
|
||||||
static func validateDataSize(_ data: Data, maxSize: Int) -> Bool {
|
|
||||||
return data.count > 0 && data.count <= maxSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Binary Data Validation
|
|
||||||
|
|
||||||
/// Validates UUID format
|
|
||||||
static func validateUUID(_ uuid: String) -> Bool {
|
|
||||||
// Remove dashes and validate hex
|
|
||||||
let cleaned = uuid.replacingOccurrences(of: "-", with: "")
|
|
||||||
return cleaned.count == 32 && cleaned.allSatisfy { $0.isHexDigit }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates public key data
|
|
||||||
static func validatePublicKey(_ keyData: Data) -> Bool {
|
|
||||||
// Curve25519 public keys are 32 bytes
|
|
||||||
return keyData.count == 32
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates signature data
|
|
||||||
static func validateSignature(_ signature: Data) -> Bool {
|
|
||||||
// Ed25519 signatures are 64 bytes
|
|
||||||
return signature.count == 64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Character Extensions
|
|
||||||
|
|
||||||
private extension Character {
|
|
||||||
var isHexDigit: Bool {
|
|
||||||
return "0123456789abcdefABCDEF".contains(self)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Foundation
|
|||||||
struct PeerDisplayNameResolver {
|
struct PeerDisplayNameResolver {
|
||||||
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - peers: Array of tuples (id, nickname, isConnected).
|
/// - peers: Array of tuples (peerID, nickname, isConnected).
|
||||||
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
|
||||||
/// - Returns: Map of peerID -> displayName.
|
/// - Returns: Map of peerID -> displayName.
|
||||||
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
|
static func resolve(_ peers: [(peerID: PeerID, nickname: String, isConnected: Bool)], selfNickname: String) -> [PeerID: String] {
|
||||||
// Count collisions among connected peers and include our own nickname
|
// Count collisions among connected peers and include our own nickname
|
||||||
var counts: [String: Int] = [:]
|
var counts: [String: Int] = [:]
|
||||||
for p in peers where p.isConnected {
|
for p in peers where p.isConnected {
|
||||||
@@ -15,13 +15,13 @@ struct PeerDisplayNameResolver {
|
|||||||
}
|
}
|
||||||
counts[selfNickname, default: 0] += 1
|
counts[selfNickname, default: 0] += 1
|
||||||
|
|
||||||
var result: [String: String] = [:]
|
var result: [PeerID: String] = [:]
|
||||||
for p in peers {
|
for p in peers {
|
||||||
var name = p.nickname
|
var name = p.nickname
|
||||||
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
|
||||||
name += "#" + String(p.id.prefix(4))
|
name += "#" + String(p.peerID.id.prefix(4))
|
||||||
}
|
}
|
||||||
result[p.id] = name
|
result[p.peerID] = name
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct PeerIDResolver {
|
|
||||||
/// Returns a 16-hex short peer ID derived from a 64-hex Noise public key if needed
|
|
||||||
static func toShortID(_ id: String) -> String {
|
|
||||||
if id.count == 64, let data = Data(hexString: id) {
|
|
||||||
return PeerIDUtils.derivePeerID(fromPublicKey: data)
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isShortID(_ id: String) -> Bool {
|
|
||||||
return id.count == 16 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isNoiseKeyHex(_ id: String) -> Bool {
|
|
||||||
return id.count == 64 && Data(hexString: id) != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
+122
-90
@@ -18,41 +18,77 @@ struct AppInfoView: View {
|
|||||||
|
|
||||||
// MARK: - Constants
|
// MARK: - Constants
|
||||||
private enum Strings {
|
private enum Strings {
|
||||||
static let appName = "bitchat"
|
static let appName: LocalizedStringKey = "app_info.app_name"
|
||||||
static let tagline = "sidegroupchat"
|
static let tagline: LocalizedStringKey = "app_info.tagline"
|
||||||
|
|
||||||
enum Features {
|
enum Features {
|
||||||
static let title = "FEATURES"
|
static let title: LocalizedStringKey = "app_info.features.title"
|
||||||
static let offlineComm = ("wifi.slash", "offline communication", "works without internet using Bluetooth low energy")
|
static let offlineComm = AppInfoFeatureInfo(
|
||||||
static let encryption = ("lock.shield", "end-to-end encryption", "private messages encrypted with noise protocol")
|
icon: "wifi.slash",
|
||||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
title: "app_info.features.offline.title",
|
||||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
description: "app_info.features.offline.description"
|
||||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
)
|
||||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
static let encryption = AppInfoFeatureInfo(
|
||||||
|
icon: "lock.shield",
|
||||||
|
title: "app_info.features.encryption.title",
|
||||||
|
description: "app_info.features.encryption.description"
|
||||||
|
)
|
||||||
|
static let extendedRange = AppInfoFeatureInfo(
|
||||||
|
icon: "antenna.radiowaves.left.and.right",
|
||||||
|
title: "app_info.features.extended_range.title",
|
||||||
|
description: "app_info.features.extended_range.description"
|
||||||
|
)
|
||||||
|
static let mentions = AppInfoFeatureInfo(
|
||||||
|
icon: "at",
|
||||||
|
title: "app_info.features.mentions.title",
|
||||||
|
description: "app_info.features.mentions.description"
|
||||||
|
)
|
||||||
|
static let favorites = AppInfoFeatureInfo(
|
||||||
|
icon: "star.fill",
|
||||||
|
title: "app_info.features.favorites.title",
|
||||||
|
description: "app_info.features.favorites.description"
|
||||||
|
)
|
||||||
|
static let geohash = AppInfoFeatureInfo(
|
||||||
|
icon: "number",
|
||||||
|
title: "app_info.features.geohash.title",
|
||||||
|
description: "app_info.features.geohash.description"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Privacy {
|
enum Privacy {
|
||||||
static let title = "PRIVACY"
|
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||||
static let noTracking = ("eye.slash", "no tracking", "no servers, accounts, or data collection")
|
static let noTracking = AppInfoFeatureInfo(
|
||||||
static let ephemeral = ("shuffle", "ephemeral identity", "new peer ID generated regularly")
|
icon: "eye.slash",
|
||||||
static let panic = ("hand.raised.fill", "panic mode", "triple-tap logo to instantly clear all data")
|
title: "app_info.privacy.no_tracking.title",
|
||||||
|
description: "app_info.privacy.no_tracking.description"
|
||||||
|
)
|
||||||
|
static let ephemeral = AppInfoFeatureInfo(
|
||||||
|
icon: "shuffle",
|
||||||
|
title: "app_info.privacy.ephemeral.title",
|
||||||
|
description: "app_info.privacy.ephemeral.description"
|
||||||
|
)
|
||||||
|
static let panic = AppInfoFeatureInfo(
|
||||||
|
icon: "hand.raised.fill",
|
||||||
|
title: "app_info.privacy.panic.title",
|
||||||
|
description: "app_info.privacy.panic.description"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum HowToUse {
|
enum HowToUse {
|
||||||
static let title = "HOW TO USE"
|
static let title: LocalizedStringKey = "app_info.how_to_use.title"
|
||||||
static let instructions = [
|
static let instructions: [LocalizedStringKey] = [
|
||||||
"• set your nickname by tapping it",
|
"app_info.how_to_use.set_nickname",
|
||||||
"• tap #mesh to change channels",
|
"app_info.how_to_use.change_channels",
|
||||||
"• tap people icon for sidebar",
|
"app_info.how_to_use.open_sidebar",
|
||||||
"• tap a peer's name to start a DM",
|
"app_info.how_to_use.start_dm",
|
||||||
"• triple-tap chat to clear",
|
"app_info.how_to_use.clear_chat",
|
||||||
"• type / for commands"
|
"app_info.how_to_use.commands"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Warning {
|
enum Warning {
|
||||||
static let title = "WARNING"
|
static let title: LocalizedStringKey = "app_info.warning.title"
|
||||||
static let message = "private message security has not yet been fully audited. do not use for critical situations until this warning disappears."
|
static let message: LocalizedStringKey = "app_info.warning.message"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +98,7 @@ struct AppInfoView: View {
|
|||||||
// Custom header for macOS
|
// Custom header for macOS
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("DONE") {
|
Button("app_info.done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -88,12 +124,12 @@ struct AppInfoView: View {
|
|||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.accessibilityLabel("Close")
|
.accessibilityLabel("app_info.close")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,82 +142,64 @@ struct AppInfoView: View {
|
|||||||
// Header
|
// Header
|
||||||
VStack(alignment: .center, spacing: 8) {
|
VStack(alignment: .center, spacing: 8) {
|
||||||
Text(Strings.appName)
|
Text(Strings.appName)
|
||||||
.font(.system(size: 32, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(Strings.tagline)
|
Text(Strings.tagline)
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
|
|
||||||
// Features
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.Features.title)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.offlineComm.0,
|
|
||||||
title: Strings.Features.offlineComm.1,
|
|
||||||
description: Strings.Features.offlineComm.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.encryption.0,
|
|
||||||
title: Strings.Features.encryption.1,
|
|
||||||
description: Strings.Features.encryption.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.extendedRange.0,
|
|
||||||
title: Strings.Features.extendedRange.1,
|
|
||||||
description: Strings.Features.extendedRange.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.favorites.0,
|
|
||||||
title: Strings.Features.favorites.1,
|
|
||||||
description: Strings.Features.favorites.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.geohash.0,
|
|
||||||
title: Strings.Features.geohash.1,
|
|
||||||
description: Strings.Features.geohash.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Features.mentions.0,
|
|
||||||
title: Strings.Features.mentions.1,
|
|
||||||
description: Strings.Features.mentions.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Privacy
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
|
||||||
SectionHeader(Strings.Privacy.title)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.noTracking.0,
|
|
||||||
title: Strings.Privacy.noTracking.1,
|
|
||||||
description: Strings.Privacy.noTracking.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.ephemeral.0,
|
|
||||||
title: Strings.Privacy.ephemeral.1,
|
|
||||||
description: Strings.Privacy.ephemeral.2)
|
|
||||||
|
|
||||||
FeatureRow(icon: Strings.Privacy.panic.0,
|
|
||||||
title: Strings.Privacy.panic.1,
|
|
||||||
description: Strings.Privacy.panic.2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// How to Use
|
// How to Use
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
SectionHeader(Strings.HowToUse.title)
|
SectionHeader(Strings.HowToUse.title)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in
|
ForEach(Array(Strings.HowToUse.instructions.enumerated()), id: \.offset) { _, instruction in
|
||||||
Text(instruction)
|
Text(instruction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Features
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.Features.title)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.offlineComm)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.encryption)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.extendedRange)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.favorites)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.geohash)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Features.mentions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Privacy
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
SectionHeader(Strings.Privacy.title)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.noTracking)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.ephemeral)
|
||||||
|
|
||||||
|
FeatureRow(info: Strings.Privacy.panic)
|
||||||
|
}
|
||||||
|
|
||||||
// Warning
|
// Warning
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
SectionHeader(Strings.Warning.title)
|
SectionHeader(Strings.Warning.title)
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
|
|
||||||
Text(Strings.Warning.message)
|
Text(Strings.Warning.message)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.red)
|
.foregroundColor(Color.red)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -197,30 +215,34 @@ struct AppInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AppInfoFeatureInfo {
|
||||||
|
let icon: String
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let description: LocalizedStringKey
|
||||||
|
}
|
||||||
|
|
||||||
struct SectionHeader: View {
|
struct SectionHeader: View {
|
||||||
let title: String
|
let title: LocalizedStringKey
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(_ title: String) {
|
init(_ title: LocalizedStringKey) {
|
||||||
self.title = title
|
self.title = title
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FeatureRow: View {
|
struct FeatureRow: View {
|
||||||
let icon: String
|
let info: AppInfoFeatureInfo
|
||||||
let title: String
|
|
||||||
let description: String
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
private var textColor: Color {
|
private var textColor: Color {
|
||||||
@@ -233,18 +255,18 @@ struct FeatureRow: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: info.icon)
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.frame(width: 30)
|
.frame(width: 30)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(title)
|
Text(info.title)
|
||||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(description)
|
Text(info.description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
@@ -254,6 +276,16 @@ struct FeatureRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview("Default") {
|
||||||
AppInfoView()
|
AppInfoView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XXL") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .accessibilityExtraExtraExtraLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Dynamic Type XS") {
|
||||||
|
AppInfoView()
|
||||||
|
.environment(\.sizeCategory, .extraSmall)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// DeliveryStatusView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct DeliveryStatusView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
let status: DeliveryStatus
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
private var textColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var secondaryTextColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static func delivered(to nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func read(by nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func failed(_ reason: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||||
|
locale: .current,
|
||||||
|
reason
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||||
|
locale: .current,
|
||||||
|
reached,
|
||||||
|
total
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Body
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch status {
|
||||||
|
case .sending:
|
||||||
|
Image(systemName: "circle")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
|
case .sent:
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
|
||||||
|
case .delivered(let nickname, _):
|
||||||
|
HStack(spacing: -2) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
}
|
||||||
|
.foregroundColor(textColor.opacity(0.8))
|
||||||
|
.help(Strings.delivered(to: nickname))
|
||||||
|
|
||||||
|
case .read(let nickname, _):
|
||||||
|
HStack(spacing: -2) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||||
|
.help(Strings.read(by: nickname))
|
||||||
|
|
||||||
|
case .failed(let reason):
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
.foregroundColor(Color.red.opacity(0.8))
|
||||||
|
.help(Strings.failed(reason))
|
||||||
|
|
||||||
|
case .partiallyDelivered(let reached, let total):
|
||||||
|
HStack(spacing: 1) {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.bitchatSystem(size: 10))
|
||||||
|
Text(verbatim: "\(reached)/\(total)")
|
||||||
|
.font(.bitchatSystem(size: 10, design: .monospaced))
|
||||||
|
}
|
||||||
|
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||||
|
.help(Strings.deliveredToMembers(reached, total))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let statuses: [DeliveryStatus] = [
|
||||||
|
.sending,
|
||||||
|
.sent,
|
||||||
|
.delivered(to: "John Doe", at: Date()),
|
||||||
|
.read(by: "Jane Doe", at: Date()),
|
||||||
|
.failed(reason: "Offline"),
|
||||||
|
.partiallyDelivered(reached: 2, total: 5)
|
||||||
|
]
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(statuses, id: \.self) { status in
|
||||||
|
HStack {
|
||||||
|
Text(status.displayText)
|
||||||
|
Spacer()
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
ForEach(statuses, id: \.self) { status in
|
||||||
|
HStack {
|
||||||
|
Text(status.displayText)
|
||||||
|
Spacer()
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
//
|
||||||
|
// PaymentChipView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PaymentChipView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
enum PaymentType {
|
||||||
|
case cashu(String)
|
||||||
|
case lightning(String)
|
||||||
|
|
||||||
|
var url: URL? {
|
||||||
|
switch self {
|
||||||
|
case .cashu(let link), .lightning(let link):
|
||||||
|
return URL(string: link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var emoji: String {
|
||||||
|
switch self {
|
||||||
|
case .cashu: "🥜"
|
||||||
|
case .lightning: "⚡"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .cashu:
|
||||||
|
String(localized: "content.payment.cashu", comment: "Label for Cashu payment chip")
|
||||||
|
case .lightning:
|
||||||
|
String(localized: "content.payment.lightning", comment: "Label for Lightning payment chip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let paymentType: PaymentType
|
||||||
|
|
||||||
|
private var fgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
}
|
||||||
|
private var bgColor: Color {
|
||||||
|
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
|
||||||
|
}
|
||||||
|
private var border: Color { fgColor.opacity(0.25) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button {
|
||||||
|
#if os(iOS)
|
||||||
|
if let url = paymentType.url { openURL(url) }
|
||||||
|
#else
|
||||||
|
if let url = paymentType.url { NSWorkspace.shared.open(url) }
|
||||||
|
#endif
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(paymentType.emoji)
|
||||||
|
Text(paymentType.label)
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(bgColor)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.stroke(border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
.foregroundColor(fgColor)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
let cashuLink = "https://example.com/cashu"
|
||||||
|
let lightningLink = "https://example.com/lightning"
|
||||||
|
|
||||||
|
List {
|
||||||
|
HStack {
|
||||||
|
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||||
|
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
HStack {
|
||||||
|
PaymentChipView(paymentType: .cashu(cashuLink))
|
||||||
|
PaymentChipView(paymentType: .lightning(lightningLink))
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
//
|
||||||
|
// TextMessageView.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TextMessageView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||||
|
@EnvironmentObject private var viewModel: ChatViewModel
|
||||||
|
|
||||||
|
let message: BitchatMessage
|
||||||
|
@Binding var expandedMessageIDs: Set<String>
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
// Precompute heavy token scans once per row
|
||||||
|
let cashuLinks = message.content.extractCashuLinks()
|
||||||
|
let lightningLinks = message.content.extractLightningLinks()
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
// Delivery status indicator for private messages
|
||||||
|
if message.isPrivate && message.sender == viewModel.nickname,
|
||||||
|
let status = message.deliveryStatus {
|
||||||
|
DeliveryStatusView(status: status)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand/Collapse for very long messages
|
||||||
|
if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty {
|
||||||
|
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||||
|
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
|
||||||
|
Button(labelKey) {
|
||||||
|
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||||
|
else { expandedMessageIDs.insert(message.id) }
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(Color.blue)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render payment chips (Lightning / Cashu) with rounded background
|
||||||
|
if !lightningLinks.isEmpty || !cashuLinks.isEmpty {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(lightningLinks, id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .lightning(link))
|
||||||
|
}
|
||||||
|
ForEach(cashuLinks, id: \.self) { link in
|
||||||
|
PaymentChipView(paymentType: .cashu(link))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 6)
|
||||||
|
.padding(.leading, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(macOS 14, iOS 17, *)
|
||||||
|
#Preview {
|
||||||
|
@Previewable @State var ids: Set<String> = []
|
||||||
|
let keychain = PreviewKeychainManager()
|
||||||
|
|
||||||
|
Group {
|
||||||
|
List {
|
||||||
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .light)
|
||||||
|
|
||||||
|
List {
|
||||||
|
TextMessageView(message: .preview, expandedMessageIDs: $ids)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
.listRowInsets(EdgeInsets())
|
||||||
|
.listRowBackground(EmptyView())
|
||||||
|
}
|
||||||
|
.environment(\.colorScheme, .dark)
|
||||||
|
}
|
||||||
|
.environmentObject(
|
||||||
|
ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
+390
-469
File diff suppressed because it is too large
Load Diff
@@ -22,19 +22,42 @@ struct FingerprintView: View {
|
|||||||
colorScheme == .dark ? Color.black : Color.white
|
colorScheme == .dark ? Color.black : Color.white
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let title: LocalizedStringKey = "fingerprint.title"
|
||||||
|
static let theirFingerprint: LocalizedStringKey = "fingerprint.their_label"
|
||||||
|
static let handshakePending: LocalizedStringKey = "fingerprint.handshake_pending"
|
||||||
|
static let yourFingerprint: LocalizedStringKey = "fingerprint.your_label"
|
||||||
|
static let copy: LocalizedStringKey = "common.copy"
|
||||||
|
static let verifiedBadge: LocalizedStringKey = "fingerprint.badge.verified"
|
||||||
|
static let notVerifiedBadge: LocalizedStringKey = "fingerprint.badge.not_verified"
|
||||||
|
static let verifiedMessage: LocalizedStringKey = "fingerprint.message.verified"
|
||||||
|
static func verifyHint(_ nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "fingerprint.message.verify_hint", comment: "Instruction to compare fingerprints with a named peer"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
static let markVerified: LocalizedStringKey = "fingerprint.action.mark_verified"
|
||||||
|
static let removeVerification: LocalizedStringKey = "fingerprint.action.remove_verification"
|
||||||
|
static func unknownPeer() -> String {
|
||||||
|
String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
// Header
|
// Header
|
||||||
HStack {
|
HStack {
|
||||||
Text("SECURITY VERIFICATION")
|
Text(Strings.title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Button(action: { dismiss() }) {
|
Button(action: { dismiss() }) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
}
|
}
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
@@ -49,7 +72,7 @@ struct FingerprintView: View {
|
|||||||
// Resolve a friendly name
|
// Resolve a friendly name
|
||||||
let peerNickname: String = {
|
let peerNickname: String = {
|
||||||
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
|
||||||
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
|
if let name = viewModel.meshService.peerNickname(peerID: PeerID(str: statusPeerID)) { return name }
|
||||||
if peerID.count == 64, let data = Data(hexString: peerID) {
|
if peerID.count == 64, let data = Data(hexString: peerID) {
|
||||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
|
||||||
let fp = data.sha256Fingerprint()
|
let fp = data.sha256Fingerprint()
|
||||||
@@ -58,7 +81,7 @@ struct FingerprintView: View {
|
|||||||
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
if !social.claimedNickname.isEmpty { return social.claimedNickname }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "Unknown"
|
return Strings.unknownPeer()
|
||||||
}()
|
}()
|
||||||
// Accurate encryption state based on short ID session
|
// Accurate encryption state based on short ID session
|
||||||
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = viewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
@@ -66,17 +89,17 @@ struct FingerprintView: View {
|
|||||||
HStack {
|
HStack {
|
||||||
if let icon = encryptionStatus.icon {
|
if let icon = encryptionStatus.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
.foregroundColor(encryptionStatus == .noiseVerified ? Color.green : textColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(peerNickname)
|
Text(peerNickname)
|
||||||
.font(.system(size: 18, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
Text(encryptionStatus.description)
|
Text(encryptionStatus.description)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +111,13 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
// Their fingerprint
|
// Their fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("THEIR FINGERPRINT:")
|
Text(Strings.theirFingerprint)
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
if let fingerprint = viewModel.getFingerprint(for: statusPeerID) {
|
||||||
Text(formatFingerprint(fingerprint))
|
Text(formatFingerprint(fingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -104,7 +127,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = fingerprint
|
UIPasteboard.general.string = fingerprint
|
||||||
#else
|
#else
|
||||||
@@ -114,8 +137,8 @@ struct FingerprintView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("not available - handshake in progress")
|
Text(Strings.handshakePending)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(Color.orange)
|
.foregroundColor(Color.orange)
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
@@ -123,13 +146,13 @@ struct FingerprintView: View {
|
|||||||
|
|
||||||
// My fingerprint
|
// My fingerprint
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("YOUR FINGERPRINT:")
|
Text(Strings.yourFingerprint)
|
||||||
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
|
|
||||||
let myFingerprint = viewModel.getMyFingerprint()
|
let myFingerprint = viewModel.getMyFingerprint()
|
||||||
Text(formatFingerprint(myFingerprint))
|
Text(formatFingerprint(myFingerprint))
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -139,7 +162,7 @@ struct FingerprintView: View {
|
|||||||
.background(Color.gray.opacity(0.1))
|
.background(Color.gray.opacity(0.1))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
Button("Copy") {
|
Button(Strings.copy) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
UIPasteboard.general.string = myFingerprint
|
UIPasteboard.general.string = myFingerprint
|
||||||
#else
|
#else
|
||||||
@@ -155,15 +178,19 @@ struct FingerprintView: View {
|
|||||||
let isVerified = encryptionStatus == .noiseVerified
|
let isVerified = encryptionStatus == .noiseVerified
|
||||||
|
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text(isVerified ? "✓ VERIFIED" : "⚠️ NOT VERIFIED")
|
Text(isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(isVerified ? Color.green : Color.orange)
|
.foregroundColor(isVerified ? Color.green : Color.orange)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
Text(isVerified ?
|
Group {
|
||||||
"you have verified this person's identity." :
|
if isVerified {
|
||||||
"compare these fingerprints with \(peerNickname) using a secure channel.")
|
Text(Strings.verifiedMessage)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
} else {
|
||||||
|
Text(Strings.verifyHint(peerNickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(textColor.opacity(0.7))
|
.foregroundColor(textColor.opacity(0.7))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.lineLimit(nil)
|
.lineLimit(nil)
|
||||||
@@ -175,8 +202,8 @@ struct FingerprintView: View {
|
|||||||
viewModel.verifyFingerprint(for: peerID)
|
viewModel.verifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("MARK AS VERIFIED")
|
Text(Strings.markVerified)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
@@ -189,8 +216,8 @@ struct FingerprintView: View {
|
|||||||
viewModel.unverifyFingerprint(for: peerID)
|
viewModel.unverifyFingerprint(for: peerID)
|
||||||
dismiss()
|
dismiss()
|
||||||
}) {
|
}) {
|
||||||
Text("REMOVE VERIFICATION")
|
Text(Strings.removeVerification)
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|||||||
@@ -8,11 +8,19 @@ struct GeohashPeopleList: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||||
|
static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix"
|
||||||
|
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||||
|
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||||
|
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
if viewModel.visibleGeohashPeople().isEmpty {
|
if viewModel.visibleGeohashPeople().isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
@@ -51,32 +59,32 @@ struct GeohashPeopleList: View {
|
|||||||
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
|
||||||
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
|
||||||
let rowColor: Color = isMe ? .orange : assignedColor
|
let rowColor: Color = isMe ? .orange : assignedColor
|
||||||
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
|
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||||
|
|
||||||
let (base, suffix) = splitSuffix(from: person.displayName)
|
let (base, suffix) = person.displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(isMe ? .bold : .regular)
|
.fontWeight(isMe ? .bold : .regular)
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
if isMe {
|
if isMe {
|
||||||
Text(" (you)")
|
Text(Strings.youSuffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(rowColor)
|
.foregroundColor(rowColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let me = myHex, person.id != me {
|
if let me = myHex, person.id != me {
|
||||||
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked in geochash")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -97,9 +105,9 @@ struct GeohashPeopleList: View {
|
|||||||
} else {
|
} else {
|
||||||
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
if blocked {
|
if blocked {
|
||||||
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
Button(Strings.unblock) { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
} else {
|
} else {
|
||||||
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
Button(Strings.block) { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,16 +126,3 @@ struct GeohashPeopleList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
|
||||||
guard name.count >= 5 else { return (name, "") }
|
|
||||||
let suffix = String(name.suffix(5))
|
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
||||||
}) {
|
|
||||||
let base = String(name.dropLast(5))
|
|
||||||
return (base, suffix)
|
|
||||||
}
|
|
||||||
return (name, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,21 +17,81 @@ struct LocationChannelsSheet: View {
|
|||||||
|
|
||||||
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let title: LocalizedStringKey = "location_channels.title"
|
||||||
|
static let description: LocalizedStringKey = "location_channels.description"
|
||||||
|
static let requestPermissions: LocalizedStringKey = "location_channels.action.request_permissions"
|
||||||
|
static let permissionDenied: LocalizedStringKey = "location_channels.permission_denied"
|
||||||
|
static let openSettings: LocalizedStringKey = "location_channels.action.open_settings"
|
||||||
|
static let loadingNearby: LocalizedStringKey = "location_channels.loading_nearby"
|
||||||
|
static let teleport: LocalizedStringKey = "location_channels.action.teleport"
|
||||||
|
static let bookmarked: LocalizedStringKey = "location_channels.bookmarked_section_title"
|
||||||
|
static let removeAccess: LocalizedStringKey = "location_channels.action.remove_access"
|
||||||
|
static let torTitle: LocalizedStringKey = "location_channels.tor.title"
|
||||||
|
static let torSubtitle: LocalizedStringKey = "location_channels.tor.subtitle"
|
||||||
|
static let toggleOn: LocalizedStringKey = "common.toggle.on"
|
||||||
|
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||||
|
|
||||||
|
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||||
|
|
||||||
|
static func meshTitle(_ count: Int) -> String {
|
||||||
|
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||||
|
return rowTitle(label: label, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||||
|
return rowTitle(label: level.displayName, count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||||
|
return rowTitle(label: "#\(geohash)", count: count)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtitlePrefix(geohash: String, coverage: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_channels.subtitle_prefix", comment: "Subtitle prefix showing geohash and coverage"),
|
||||||
|
locale: .current,
|
||||||
|
geohash, coverage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func subtitle(prefix: String, name: String?) -> String {
|
||||||
|
guard let name, !name.isEmpty else { return prefix }
|
||||||
|
return String(
|
||||||
|
format: String(localized: "location_channels.subtitle_with_name", comment: "Subtitle combining prefix and resolved location name"),
|
||||||
|
locale: .current,
|
||||||
|
prefix, name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func rowTitle(label: String, count: Int) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "location_channels.row_title", comment: "List row title with participant count"),
|
||||||
|
locale: .current,
|
||||||
|
label, count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("#location channels")
|
HStack(spacing: 12) {
|
||||||
.font(.system(size: 18, design: .monospaced))
|
Text(Strings.title)
|
||||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.")
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
.font(.system(size: 12, design: .monospaced))
|
Spacer()
|
||||||
|
closeButton
|
||||||
|
}
|
||||||
|
Text(Strings.description)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
switch manager.permissionState {
|
switch manager.permissionState {
|
||||||
case LocationChannelManager.PermissionState.notDetermined:
|
case LocationChannelManager.PermissionState.notDetermined:
|
||||||
Button(action: { manager.enableLocationChannels() }) {
|
Button(action: { manager.enableLocationChannels() }) {
|
||||||
Text("get location and my geohashes")
|
Text(Strings.requestPermissions)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
@@ -41,10 +101,10 @@ struct LocationChannelsSheet: View {
|
|||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("location permission denied. enable in settings to use location channels.")
|
Text(Strings.permissionDenied)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Button("open settings") { openSystemLocationSettings() }
|
Button(Strings.openSettings) { openSystemLocationSettings() }
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
case LocationChannelManager.PermissionState.authorized:
|
case LocationChannelManager.PermissionState.authorized:
|
||||||
@@ -60,29 +120,9 @@ struct LocationChannelsSheet: View {
|
|||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.navigationBarHidden(true)
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#else
|
#else
|
||||||
.toolbar {
|
.navigationTitle("")
|
||||||
ToolbarItem(placement: .automatic) {
|
|
||||||
Button(action: { isPresented = false }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.frame(width: 20, height: 20)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -112,183 +152,225 @@ struct LocationChannelsSheet: View {
|
|||||||
.onChange(of: manager.availableChannels) { _ in }
|
.onChange(of: manager.availableChannels) { _ in }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var channelList: some View {
|
private var closeButton: some View {
|
||||||
List {
|
Button(action: { isPresented = false }) {
|
||||||
// Mesh option first (no bookmark)
|
Image(systemName: "xmark")
|
||||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
manager.select(ChannelID.mesh)
|
.frame(width: 32, height: 32)
|
||||||
isPresented = false
|
}
|
||||||
}
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel("Close")
|
||||||
|
}
|
||||||
|
|
||||||
// Nearby options
|
private var channelList: some View {
|
||||||
if !manager.availableChannels.isEmpty {
|
ScrollView {
|
||||||
ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in
|
LazyVStack(spacing: 0) {
|
||||||
let coverage = coverageString(forPrecision: channel.geohash.count)
|
channelRow(title: Strings.meshTitle(meshCount()), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||||
let nameBase = locationName(for: channel.level)
|
manager.select(ChannelID.mesh)
|
||||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
isPresented = false
|
||||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
}
|
||||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
|
let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||||
|
if !nearby.isEmpty {
|
||||||
|
ForEach(nearby) { channel in
|
||||||
|
sectionDivider
|
||||||
|
let coverage = coverageString(forPrecision: channel.geohash.count)
|
||||||
|
let nameBase = locationName(for: channel.level)
|
||||||
|
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||||
|
let participantCount = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||||
|
let subtitlePrefix = Strings.subtitlePrefix(geohash: channel.geohash, coverage: coverage)
|
||||||
|
let highlight = participantCount > 0
|
||||||
|
channelRow(
|
||||||
|
title: Strings.levelTitle(for: channel.level, count: participantCount),
|
||||||
|
subtitlePrefix: subtitlePrefix,
|
||||||
|
subtitleName: namePart,
|
||||||
|
isSelected: isSelected(channel),
|
||||||
|
titleBold: highlight,
|
||||||
|
trailingAccessory: {
|
||||||
|
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||||
|
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, 8)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
manager.markTeleported(for: channel.geohash, false)
|
||||||
|
manager.select(ChannelID.location(channel))
|
||||||
|
isPresented = false
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sectionDivider
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
Text(Strings.loadingNearby)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionDivider
|
||||||
|
customTeleportSection
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
let bookmarkedList = bookmarks.bookmarks
|
||||||
|
if !bookmarkedList.isEmpty {
|
||||||
|
sectionDivider
|
||||||
|
bookmarkedSection(bookmarkedList)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||||
|
sectionDivider
|
||||||
|
torToggleSection
|
||||||
|
.padding(.top, 12)
|
||||||
|
Button(action: {
|
||||||
|
openSystemLocationSettings()
|
||||||
|
}) {
|
||||||
|
Text(Strings.removeAccess)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.red.opacity(0.08))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionDivider: some View {
|
||||||
|
Rectangle()
|
||||||
|
.fill(dividerColor)
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var dividerColor: Color {
|
||||||
|
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var customTeleportSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Text(verbatim: "#")
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
TextField("geohash", text: $customGeohash)
|
||||||
|
#if os(iOS)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled(true)
|
||||||
|
.keyboardType(.asciiCapable)
|
||||||
|
#endif
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.onChange(of: customGeohash) { newValue in
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
let filtered = newValue
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
|
.filter { allowed.contains($0) }
|
||||||
|
if filtered.count > 12 {
|
||||||
|
customGeohash = String(filtered.prefix(12))
|
||||||
|
} else if filtered != newValue {
|
||||||
|
customGeohash = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let normalized = customGeohash
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: "#", with: "")
|
||||||
|
let isValid = validateGeohash(normalized)
|
||||||
|
Button(action: {
|
||||||
|
let gh = normalized
|
||||||
|
guard isValid else { customError = Strings.invalidGeohash; return }
|
||||||
|
let level = levelForLength(gh.count)
|
||||||
|
let ch = GeohashChannel(level: level, geohash: gh)
|
||||||
|
manager.markTeleported(for: ch.geohash, true)
|
||||||
|
manager.select(ChannelID.location(ch))
|
||||||
|
isPresented = false
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(Strings.teleport)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
Image(systemName: "face.dashed")
|
||||||
|
.font(.bitchatSystem(size: 14))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(Color.secondary.opacity(0.12))
|
||||||
|
.cornerRadius(6)
|
||||||
|
.opacity(isValid ? 1.0 : 0.4)
|
||||||
|
.disabled(!isValid)
|
||||||
|
}
|
||||||
|
if let err = customError {
|
||||||
|
Text(err)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(Strings.bookmarked)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||||
|
let level = levelForLength(gh.count)
|
||||||
|
let channel = GeohashChannel(level: level, geohash: gh)
|
||||||
|
let coverage = coverageString(forPrecision: gh.count)
|
||||||
|
let subtitle = Strings.subtitlePrefix(geohash: gh, coverage: coverage)
|
||||||
|
let name = bookmarks.bookmarkNames[gh]
|
||||||
|
let participantCount = viewModel.geohashParticipantCount(for: gh)
|
||||||
channelRow(
|
channelRow(
|
||||||
title: geohashTitleWithCount(for: channel),
|
title: Strings.bookmarkTitle(geohash: gh, count: participantCount),
|
||||||
subtitlePrefix: subtitlePrefix,
|
subtitlePrefix: subtitle,
|
||||||
subtitleName: namePart,
|
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
titleBold: highlight,
|
|
||||||
trailingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { bookmarks.toggle(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.bitchatSystem(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 8)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||||
manager.markTeleported(for: channel.geohash, false)
|
if !inRegional && !manager.availableChannels.isEmpty {
|
||||||
|
manager.markTeleported(for: gh, true)
|
||||||
|
} else {
|
||||||
|
manager.markTeleported(for: gh, false)
|
||||||
|
}
|
||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HStack {
|
|
||||||
ProgressView()
|
|
||||||
Text("finding nearby channels…")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom geohash teleport
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
HStack(spacing: 2) {
|
|
||||||
Text("#")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
TextField("geohash", text: $customGeohash)
|
|
||||||
#if os(iOS)
|
|
||||||
.textInputAutocapitalization(.never)
|
|
||||||
.autocorrectionDisabled(true)
|
|
||||||
.keyboardType(.asciiCapable)
|
|
||||||
#endif
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.onChange(of: customGeohash) { newValue in
|
|
||||||
// Allow only geohash base32 characters, strip '#', limit length
|
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
let filtered = newValue
|
|
||||||
.lowercased()
|
|
||||||
.replacingOccurrences(of: "#", with: "")
|
|
||||||
.filter { allowed.contains($0) }
|
|
||||||
if filtered.count > 12 {
|
|
||||||
customGeohash = String(filtered.prefix(12))
|
|
||||||
} else if filtered != newValue {
|
|
||||||
customGeohash = filtered
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
|
||||||
let isValid = validateGeohash(normalized)
|
|
||||||
Button(action: {
|
|
||||||
let gh = normalized
|
|
||||||
guard isValid else { customError = "invalid geohash"; return }
|
|
||||||
let level = levelForLength(gh.count)
|
|
||||||
let ch = GeohashChannel(level: level, geohash: gh)
|
|
||||||
// Mark this selection as a manual teleport
|
|
||||||
manager.markTeleported(for: ch.geohash, true)
|
|
||||||
manager.select(ChannelID.location(ch))
|
|
||||||
isPresented = false
|
|
||||||
}) {
|
|
||||||
HStack(spacing: 6) {
|
|
||||||
Text("teleport")
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
Image(systemName: "face.dashed")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.font(.system(size: 14, design: .monospaced))
|
|
||||||
.padding(.horizontal, 10)
|
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
.cornerRadius(6)
|
|
||||||
.opacity(isValid ? 1.0 : 0.4)
|
|
||||||
.disabled(!isValid)
|
|
||||||
}
|
|
||||||
if let err = customError {
|
|
||||||
Text(err)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(.red)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bookmarked geohashes
|
if index < entries.count - 1 {
|
||||||
if !bookmarks.bookmarks.isEmpty {
|
sectionDivider
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("bookmarked")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
|
||||||
let level = levelForLength(gh.count)
|
|
||||||
let channel = GeohashChannel(level: level, geohash: gh)
|
|
||||||
let coverage = coverageString(forPrecision: gh.count)
|
|
||||||
let subtitle = "#\(gh) • \(coverage)"
|
|
||||||
let name = bookmarks.bookmarkNames[gh]
|
|
||||||
channelRow(
|
|
||||||
title: geohashHashTitleWithCount(gh),
|
|
||||||
subtitlePrefix: subtitle,
|
|
||||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
|
||||||
isSelected: isSelected(channel),
|
|
||||||
trailingAccessory: {
|
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
|
||||||
.font(.system(size: 14))
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.padding(.leading, 8)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
// For bookmarked selection, mark teleported based on regional membership
|
|
||||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
|
||||||
if !inRegional && !manager.availableChannels.isEmpty {
|
|
||||||
manager.markTeleported(for: gh, true)
|
|
||||||
} else {
|
|
||||||
manager.markTeleported(for: gh, false)
|
|
||||||
}
|
|
||||||
manager.select(ChannelID.location(channel))
|
|
||||||
isPresented = false
|
|
||||||
}
|
|
||||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(12)
|
|
||||||
.background(Color.secondary.opacity(0.12))
|
|
||||||
.cornerRadius(8)
|
|
||||||
}
|
}
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer action inside the list
|
|
||||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
|
||||||
torToggleSection
|
|
||||||
Button(action: {
|
|
||||||
openSystemLocationSettings()
|
|
||||||
}) {
|
|
||||||
Text("remove location access")
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
.background(Color.red.opacity(0.08))
|
|
||||||
.cornerRadius(6)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.background(backgroundColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
if case .location(let ch) = manager.selectedChannel {
|
if case .location(let ch) = manager.selectedChannel {
|
||||||
return ch == channel
|
return ch == channel
|
||||||
@@ -319,31 +401,26 @@ struct LocationChannelsSheet: View {
|
|||||||
let parts = splitTitleAndCount(title)
|
let parts = splitTitleAndCount(title)
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(parts.base)
|
Text(parts.base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.fontWeight(titleBold ? .bold : .regular)
|
.fontWeight(titleBold ? .bold : .regular)
|
||||||
.foregroundColor(titleColor ?? Color.primary)
|
.foregroundColor(titleColor ?? Color.primary)
|
||||||
if let count = parts.countSuffix, !count.isEmpty {
|
if let count = parts.countSuffix, !count.isEmpty {
|
||||||
Text(count)
|
Text(count)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let subtitleFull: String = {
|
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||||
if let name = subtitleName, !name.isEmpty {
|
|
||||||
return subtitlePrefix + " • " + name
|
|
||||||
}
|
|
||||||
return subtitlePrefix
|
|
||||||
}()
|
|
||||||
Text(subtitleFull)
|
Text(subtitleFull)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
if isSelected {
|
if isSelected {
|
||||||
Text("✔︎")
|
Text(verbatim: "✔︎")
|
||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
trailingAccessory()
|
trailingAccessory()
|
||||||
@@ -362,36 +439,15 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers for counts
|
// MARK: - Helpers for counts
|
||||||
private func meshTitleWithCount() -> String {
|
|
||||||
// Count currently connected mesh peers (excluding self)
|
|
||||||
let meshCount = meshCount()
|
|
||||||
let noun = meshCount == 1 ? "person" : "people"
|
|
||||||
return "mesh [\(meshCount) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func meshCount() -> Int {
|
private func meshCount() -> Int {
|
||||||
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
// Count mesh-connected OR mesh-reachable peers (exclude self)
|
||||||
let myID = viewModel.meshService.myPeerID
|
let myID = viewModel.meshService.myPeerID
|
||||||
return viewModel.allPeers.reduce(0) { acc, peer in
|
return viewModel.allPeers.reduce(0) { acc, peer in
|
||||||
if peer.id != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
if peer.peerID != myID && (peer.isConnected || peer.isReachable) { return acc + 1 }
|
||||||
return acc
|
return acc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
|
||||||
// Main list: keep level labels (block/neighborhood/city/province/region)
|
|
||||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
|
||||||
let noun = count == 1 ? "person" : "people"
|
|
||||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func geohashHashTitleWithCount(_ geohash: String) -> String {
|
|
||||||
// Bookmarked list: show the #geohash as the main label
|
|
||||||
let count = viewModel.geohashParticipantCount(for: geohash)
|
|
||||||
let noun = count == 1 ? "person" : "people"
|
|
||||||
return "#\(geohash) [\(count) \(noun)]"
|
|
||||||
}
|
|
||||||
|
|
||||||
private func validateGeohash(_ s: String) -> Bool {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
@@ -424,21 +480,19 @@ extension LocationChannelsSheet {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Toggle(isOn: torToggleBinding) {
|
Toggle(isOn: torToggleBinding) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("tor routing")
|
Text(Strings.torTitle)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(.primary)
|
.foregroundColor(.primary)
|
||||||
Text("hides your ip for location channels. recommended: on.")
|
Text(Strings.torSubtitle)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.toggleStyle(IRCToggleStyle(accent: standardGreen))
|
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
|
||||||
}
|
}
|
||||||
.padding(12)
|
.padding(12)
|
||||||
.background(Color.secondary.opacity(0.12))
|
.background(Color.secondary.opacity(0.12))
|
||||||
.cornerRadius(8)
|
.cornerRadius(8)
|
||||||
.listRowSeparator(.hidden)
|
|
||||||
.listRowBackground(Color.clear)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var standardGreen: Color {
|
private var standardGreen: Color {
|
||||||
@@ -451,15 +505,17 @@ extension LocationChannelsSheet {
|
|||||||
|
|
||||||
private struct IRCToggleStyle: ToggleStyle {
|
private struct IRCToggleStyle: ToggleStyle {
|
||||||
let accent: Color
|
let accent: Color
|
||||||
|
let onLabel: LocalizedStringKey
|
||||||
|
let offLabel: LocalizedStringKey
|
||||||
|
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
Button(action: { configuration.isOn.toggle() }) {
|
Button(action: { configuration.isOn.toggle() }) {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
configuration.label
|
configuration.label
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(configuration.isOn ? "on" : "off")
|
Text(configuration.isOn ? onLabel : offLabel)
|
||||||
.textCase(.uppercase)
|
.textCase(.uppercase)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
.foregroundColor(configuration.isOn ? accent : .secondary)
|
.foregroundColor(configuration.isOn ? accent : .secondary)
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.horizontal, 10)
|
.padding(.horizontal, 10)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct LocationNotesView: View {
|
|||||||
let onNotesCountChanged: ((Int) -> Void)?
|
let onNotesCountChanged: ((Int) -> Void)?
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var draft: String = ""
|
@State private var draft: String = ""
|
||||||
@@ -18,30 +19,39 @@ struct LocationNotesView: View {
|
|||||||
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh))
|
||||||
}
|
}
|
||||||
|
|
||||||
private var backgroundColor: Color {
|
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
|
||||||
colorScheme == .dark ? Color.black : Color.white
|
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
|
||||||
}
|
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
|
||||||
private var textColor: Color {
|
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
private enum Strings {
|
||||||
}
|
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||||
private var secondaryTextColor: Color {
|
static let description: LocalizedStringKey = "location_notes.description"
|
||||||
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
|
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||||
}
|
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||||
// Slightly darker green for hash suffix emphasis
|
static let noRelaysNearby: LocalizedStringKey = "location_notes.no_relays_nearby"
|
||||||
private var darkerTextColor: Color {
|
static let retry: LocalizedStringKey = "location_notes.action.retry"
|
||||||
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0)
|
static let relaysRetryHint: LocalizedStringKey = "location_notes.relays_retry_hint"
|
||||||
|
static let loadingNotes: LocalizedStringKey = "location_notes.loading_notes"
|
||||||
|
static let emptyTitle: LocalizedStringKey = "location_notes.empty_title"
|
||||||
|
static let emptySubtitle: LocalizedStringKey = "location_notes.empty_subtitle"
|
||||||
|
static let dismissError: LocalizedStringKey = "location_notes.action.dismiss"
|
||||||
|
static let addPlaceholder: LocalizedStringKey = "location_notes.placeholder"
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
#if os(macOS)
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
header
|
ScrollView {
|
||||||
Divider()
|
VStack(spacing: 0) {
|
||||||
list
|
headerSection
|
||||||
Divider()
|
notesContent
|
||||||
input
|
}
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
inputSection
|
||||||
}
|
}
|
||||||
|
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.foregroundColor(textColor)
|
|
||||||
.onDisappear { manager.cancel() }
|
.onDisappear { manager.cancel() }
|
||||||
.onChange(of: geohash) { newValue in
|
.onChange(of: geohash) { newValue in
|
||||||
manager.setGeohash(newValue)
|
manager.setGeohash(newValue)
|
||||||
@@ -50,100 +60,206 @@ struct LocationNotesView: View {
|
|||||||
.onChange(of: manager.notes.count) { newValue in
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
onNotesCountChanged?(newValue)
|
onNotesCountChanged?(newValue)
|
||||||
}
|
}
|
||||||
|
#else
|
||||||
|
NavigationView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
headerSection
|
||||||
|
ScrollView {
|
||||||
|
notesContent
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
inputSection
|
||||||
|
}
|
||||||
|
.background(backgroundColor)
|
||||||
|
#if os(iOS)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.navigationBarHidden(true)
|
||||||
|
#else
|
||||||
|
.navigationTitle("")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
#if os(iOS)
|
||||||
|
.presentationDetents([.large])
|
||||||
|
#endif
|
||||||
|
.background(backgroundColor)
|
||||||
|
.onDisappear { manager.cancel() }
|
||||||
|
.onChange(of: geohash) { newValue in
|
||||||
|
manager.setGeohash(newValue)
|
||||||
|
}
|
||||||
|
.onAppear { onNotesCountChanged?(manager.notes.count) }
|
||||||
|
.onChange(of: manager.notes.count) { newValue in
|
||||||
|
onNotesCountChanged?(newValue)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var closeButton: some View {
|
||||||
HStack {
|
Button(action: { dismiss() }) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Image(systemName: "xmark")
|
||||||
HStack(spacing: 4) {
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
let c = manager.notes.count
|
.frame(width: 32, height: 32)
|
||||||
Text("\(c) \(c == 1 ? "note" : "notes") ")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
Text("@ ")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
Text("#\(geohash)")
|
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
}
|
|
||||||
if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty {
|
|
||||||
Text(buildingName)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
} else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty {
|
|
||||||
Text(blockName)
|
|
||||||
.font(.system(size: 12, design: .monospaced))
|
|
||||||
.foregroundColor(secondaryTextColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
Button(action: { dismiss() }) {
|
|
||||||
Image(systemName: "xmark")
|
|
||||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
|
||||||
.foregroundColor(textColor)
|
|
||||||
.frame(width: 32, height: 32)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.accessibilityLabel("Close")
|
|
||||||
}
|
}
|
||||||
.frame(height: 44)
|
.buttonStyle(.plain)
|
||||||
.padding(.horizontal, 12)
|
.accessibilityLabel(Strings.closeAccessibility)
|
||||||
.background(backgroundColor.opacity(0.95))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var headerSection: some View {
|
||||||
ScrollView {
|
let count = manager.notes.count
|
||||||
LazyVStack(alignment: .leading, spacing: 8) {
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
ForEach(manager.notes) { note in
|
HStack(spacing: 12) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
Text(headerTitle(for: count))
|
||||||
HStack(spacing: 6) {
|
.font(.bitchatSystem(size: 18, design: .monospaced))
|
||||||
// Show @name without the #abcd suffix; timestamp in brackets
|
Spacer()
|
||||||
HStack(spacing: 0) {
|
closeButton
|
||||||
Text("@")
|
}
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
if let building = locationManager.locationNames[.building], !building.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(building)
|
||||||
let parts = splitSuffix(from: note.displayName)
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
Text(parts.0)
|
.foregroundColor(accentGreen)
|
||||||
.font(.system(size: 12, weight: .semibold, design: .monospaced))
|
} else if let block = locationManager.locationNames[.block], !block.isEmpty {
|
||||||
.foregroundColor(textColor)
|
Text(block)
|
||||||
}
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
let ts = timestampText(for: note.createdAt)
|
.foregroundColor(accentGreen)
|
||||||
Text(ts.isEmpty ? "" : "[\(ts)]")
|
}
|
||||||
.font(.system(size: 11, design: .monospaced))
|
Text(Strings.description)
|
||||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
.foregroundColor(.secondary)
|
||||||
Text(note.content)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
if manager.state == .noRelays {
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Text(Strings.relaysPaused)
|
||||||
}
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.padding(.horizontal, 12)
|
.foregroundColor(.secondary)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.vertical, 8)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 12)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var input: some View {
|
private func headerTitle(for count: Int) -> String {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
String(
|
||||||
TextField("add a note for this place", text: $draft, axis: .vertical)
|
format: String(localized: "location_notes.header", comment: "Header displaying the geohash and localized note count"),
|
||||||
.textFieldStyle(.plain)
|
locale: .current,
|
||||||
.font(.system(size: 14, design: .monospaced))
|
geohash, count
|
||||||
.lineLimit(3, reservesSpace: true)
|
)
|
||||||
.padding(.horizontal, 12)
|
}
|
||||||
|
|
||||||
|
private var notesContent: some View {
|
||||||
|
LazyVStack(alignment: .leading, spacing: 12) {
|
||||||
|
if manager.state == .noRelays {
|
||||||
|
noRelaysRow
|
||||||
|
} else if manager.state == .loading && !manager.initialLoadComplete {
|
||||||
|
loadingRow
|
||||||
|
} else if manager.notes.isEmpty {
|
||||||
|
emptyRow
|
||||||
|
} else {
|
||||||
|
ForEach(manager.notes) { note in
|
||||||
|
noteRow(note)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error = manager.errorMessage, manager.state != .noRelays {
|
||||||
|
errorRow(message: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func noteRow(_ note: LocationNotesManager.Note) -> some View {
|
||||||
|
let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName
|
||||||
|
let ts = timestampText(for: note.createdAt)
|
||||||
|
return VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(verbatim: "@\(baseName)")
|
||||||
|
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||||
|
if !ts.isEmpty {
|
||||||
|
Text(ts)
|
||||||
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Text(note.content)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var noRelaysRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.noRelaysNearby)
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text(Strings.relaysRetryHint)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Button(Strings.retry) { manager.refresh() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingRow: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text(Strings.loadingNotes)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyRow: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(Strings.emptyTitle)
|
||||||
|
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||||
|
Text(Strings.emptySubtitle)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func errorRow(message: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
Text(message)
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
Button(Strings.dismissError) { manager.clearError() }
|
||||||
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var inputSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 10) {
|
||||||
|
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
|
.lineLimit(maxDraftLines, reservesSpace: true)
|
||||||
|
.padding(.vertical, 6)
|
||||||
Button(action: send) {
|
Button(action: send) {
|
||||||
Image(systemName: "arrow.up.circle.fill")
|
Image(systemName: "arrow.up.circle.fill")
|
||||||
.font(.system(size: 20))
|
.font(.bitchatSystem(size: 20))
|
||||||
.foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor)
|
.foregroundColor(sendButtonEnabled ? accentGreen : .secondary)
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
.disabled(!sendButtonEnabled)
|
||||||
.padding(.trailing, 12)
|
|
||||||
}
|
}
|
||||||
.frame(minHeight: 44)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 14)
|
||||||
.background(backgroundColor.opacity(0.95))
|
.background(backgroundColor)
|
||||||
|
.overlay(Divider(), alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func send() {
|
private func send() {
|
||||||
@@ -153,15 +269,17 @@ struct LocationNotesView: View {
|
|||||||
draft = ""
|
draft = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sendButtonEnabled: Bool {
|
||||||
|
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Timestamp Formatting
|
// MARK: - Timestamp Formatting
|
||||||
private func timestampText(for date: Date) -> String {
|
private func timestampText(for date: Date) -> String {
|
||||||
let now = Date()
|
let now = Date()
|
||||||
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 {
|
||||||
// Relative (minute/hour/day), no seconds
|
|
||||||
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
let rel = Self.relativeFormatter.string(from: date, to: now) ?? ""
|
||||||
return rel.isEmpty ? "" : "\(rel) ago"
|
return rel.isEmpty ? "" : "\(rel) ago"
|
||||||
} else {
|
} else {
|
||||||
// Absolute date (MMM d or MMM d, yyyy if different year)
|
|
||||||
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year)
|
||||||
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter
|
||||||
return fmt.string(from: date)
|
return fmt.string(from: date)
|
||||||
@@ -189,16 +307,3 @@ struct LocationNotesView: View {
|
|||||||
return f
|
return f
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
|
||||||
guard name.count >= 5 else { return (name, "") }
|
|
||||||
let suffix = String(name.suffix(5))
|
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
||||||
}) {
|
|
||||||
let base = String(name.dropLast(5))
|
|
||||||
return (base, suffix)
|
|
||||||
}
|
|
||||||
return (name, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,103 +11,109 @@ struct MeshPeerList: View {
|
|||||||
|
|
||||||
@State private var orderedIDs: [String] = []
|
@State private var orderedIDs: [String] = []
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby"
|
||||||
|
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator")
|
||||||
|
static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let myPeerID = viewModel.meshService.myPeerID
|
||||||
|
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
||||||
|
let isMe = peer.peerID == myPeerID
|
||||||
|
let hasUnread = viewModel.hasUnreadMessages(for: peer.peerID.id)
|
||||||
|
let enc = viewModel.getEncryptionStatus(for: peer.peerID.id)
|
||||||
|
return (peer, isMe, hasUnread, enc)
|
||||||
|
}
|
||||||
|
// Stable visual order without mutating state here
|
||||||
|
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||||
|
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
||||||
|
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
||||||
|
mapped.first(where: { $0.peer.peerID.id == id })
|
||||||
|
}
|
||||||
|
|
||||||
if viewModel.allPeers.isEmpty {
|
if viewModel.allPeers.isEmpty {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Text("nobody around...")
|
Text(Strings.noneNearby)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let myPeerID = viewModel.meshService.myPeerID
|
|
||||||
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
|
|
||||||
let isMe = peer.id == myPeerID
|
|
||||||
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
|
|
||||||
let enc = viewModel.getEncryptionStatus(for: peer.id)
|
|
||||||
return (peer, isMe, hasUnread, enc)
|
|
||||||
}
|
|
||||||
// Stable visual order without mutating state here
|
|
||||||
let currentIDs = mapped.map { $0.peer.id }
|
|
||||||
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
|
|
||||||
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
|
|
||||||
mapped.first(where: { $0.peer.id == id })
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
ForEach(0..<peers.count, id: \.self) { idx in
|
ForEach(0..<peers.count, id: \.self) { idx in
|
||||||
let item = peers[idx]
|
let item = peers[idx]
|
||||||
let peer = item.peer
|
let peer = item.peer
|
||||||
let isMe = item.isMe
|
let isMe = item.isMe
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
|
let assigned = viewModel.colorForMeshPeer(id: peer.peerID.id, isDark: colorScheme == .dark)
|
||||||
let baseColor = isMe ? Color.orange : assigned
|
let baseColor = isMe ? Color.orange : assigned
|
||||||
if isMe {
|
if isMe {
|
||||||
Image(systemName: "person.fill")
|
Image(systemName: "person.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isConnected {
|
} else if peer.isConnected {
|
||||||
// Mesh-connected peer: radio icon
|
// Mesh-connected peer: radio icon
|
||||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isReachable {
|
} else if peer.isReachable {
|
||||||
// Mesh-reachable (relayed): point.3 icon
|
// Mesh-reachable (relayed): point.3 icon
|
||||||
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if peer.isMutualFavorite {
|
} else if peer.isMutualFavorite {
|
||||||
// Mutual favorite reachable via Nostr: globe icon (purple)
|
// Mutual favorite reachable via Nostr: globe icon (purple)
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.purple)
|
.foregroundColor(.purple)
|
||||||
} else {
|
} else {
|
||||||
// Fallback icon for others (dimmed)
|
// Fallback icon for others (dimmed)
|
||||||
Image(systemName: "person")
|
Image(systemName: "person")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(secondaryTextColor)
|
.foregroundColor(secondaryTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
let displayName = isMe ? viewModel.nickname : peer.nickname
|
let displayName = isMe ? viewModel.nickname : peer.nickname
|
||||||
let (base, suffix) = splitSuffix(from: displayName)
|
let (base, suffix) = displayName.splitSuffix()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text(base)
|
Text(base)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
if !suffix.isEmpty {
|
if !suffix.isEmpty {
|
||||||
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
|
||||||
Text(suffix)
|
Text(suffix)
|
||||||
.font(.system(size: 14, design: .monospaced))
|
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||||
.foregroundColor(suffixColor)
|
.foregroundColor(suffixColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe, viewModel.isPeerBlocked(peer.id) {
|
if !isMe, viewModel.isPeerBlocked(peer.peerID.id) {
|
||||||
Image(systemName: "nosign")
|
Image(systemName: "nosign")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(.red)
|
||||||
.help("Blocked")
|
.help(Strings.blockedTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
if peer.isConnected {
|
if peer.isConnected {
|
||||||
if let icon = item.enc.icon {
|
if let icon = item.enc.icon {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Offline: prefer showing verified badge from persisted fingerprints
|
// Offline: prefer showing verified badge from persisted fingerprints
|
||||||
if let fp = viewModel.getFingerprint(for: peer.id),
|
if let fp = viewModel.getFingerprint(for: peer.peerID.id),
|
||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Image(systemName: "checkmark.seal.fill")
|
Image(systemName: "checkmark.seal.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
} else if let icon = item.enc.icon {
|
} else if let icon = item.enc.icon {
|
||||||
// Fallback to whatever status says (likely lock if we had a past session)
|
// Fallback to whatever status says (likely lock if we had a past session)
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(baseColor)
|
.foregroundColor(baseColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,15 +124,15 @@ struct MeshPeerList: View {
|
|||||||
// Unread message indicator for this peer
|
// Unread message indicator for this peer
|
||||||
if !isMe, item.hasUnread {
|
if !isMe, item.hasUnread {
|
||||||
Image(systemName: "envelope.fill")
|
Image(systemName: "envelope.fill")
|
||||||
.font(.system(size: 10))
|
.font(.bitchatSystem(size: 10))
|
||||||
.foregroundColor(.orange)
|
.foregroundColor(.orange)
|
||||||
.help("New messages")
|
.help(Strings.newMessagesTooltip)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isMe {
|
if !isMe {
|
||||||
Button(action: { onToggleFavorite(peer.id) }) {
|
Button(action: { onToggleFavorite(peer.peerID.id) }) {
|
||||||
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
Image(systemName: (peer.favoriteStatus?.isFavorite ?? false) ? "star.fill" : "star")
|
||||||
.font(.system(size: 12))
|
.font(.bitchatSystem(size: 12))
|
||||||
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) ? .yellow : secondaryTextColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -136,16 +142,16 @@ struct MeshPeerList: View {
|
|||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
.padding(.top, idx == 0 ? 10 : 0)
|
.padding(.top, idx == 0 ? 10 : 0)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
|
.onTapGesture { if !isMe { onTapPeer(peer.peerID.id) } }
|
||||||
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
|
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.peerID.id) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Seed and update order outside result builder
|
// Seed and update order outside result builder
|
||||||
.onAppear {
|
.onAppear {
|
||||||
let currentIDs = mapped.map { $0.peer.id }
|
let currentIDs = mapped.map { $0.peer.peerID.id }
|
||||||
orderedIDs = currentIDs
|
orderedIDs = currentIDs
|
||||||
}
|
}
|
||||||
.onChange(of: mapped.map { $0.peer.id }) { ids in
|
.onChange(of: mapped.map { $0.peer.peerID.id }) { ids in
|
||||||
var newOrder = orderedIDs
|
var newOrder = orderedIDs
|
||||||
newOrder.removeAll { !ids.contains($0) }
|
newOrder.removeAll { !ids.contains($0) }
|
||||||
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
|
||||||
@@ -154,16 +160,3 @@ struct MeshPeerList: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to split a trailing #abcd suffix
|
|
||||||
private func splitSuffix(from name: String) -> (String, String) {
|
|
||||||
guard name.count >= 5 else { return (name, "") }
|
|
||||||
let suffix = String(name.suffix(5))
|
|
||||||
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
|
|
||||||
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
|
|
||||||
}) {
|
|
||||||
let base = String(name.dropLast(5))
|
|
||||||
return (base, suffix)
|
|
||||||
}
|
|
||||||
return (name, "")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -37,15 +37,16 @@ extension String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
|
||||||
func extractCashuTokens(max: Int = 3) -> [String] {
|
func extractCashuLinks(max: Int = 3) -> [String] {
|
||||||
let regex = RegexCache.cashu
|
let regex = RegexCache.cashu
|
||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let range = NSRange(location: 0, length: ns.length)
|
let range = NSRange(location: 0, length: ns.length)
|
||||||
var found: [String] = []
|
var found: [String] = []
|
||||||
for m in regex.matches(in: self, options: [], range: range) {
|
for m in regex.matches(in: self, range: range) {
|
||||||
if m.numberOfRanges > 0 {
|
if m.numberOfRanges > 0 {
|
||||||
let token = ns.substring(with: m.range(at: 0))
|
let token = ns.substring(with: m.range(at: 0))
|
||||||
found.append(token)
|
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
|
||||||
|
found.append("cashu:\(enc)")
|
||||||
if found.count >= max { break }
|
if found.count >= max { break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,19 +59,19 @@ extension String {
|
|||||||
let ns = self as NSString
|
let ns = self as NSString
|
||||||
let full = NSRange(location: 0, length: ns.length)
|
let full = NSRange(location: 0, length: ns.length)
|
||||||
// lightning: scheme
|
// lightning: scheme
|
||||||
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
|
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append(s)
|
results.append(s)
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// BOLT11
|
// BOLT11
|
||||||
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
|
for m in RegexCache.bolt11.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
}
|
}
|
||||||
// LNURL bech32
|
// LNURL bech32
|
||||||
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
|
for m in RegexCache.lnurl.matches(in: self, range: full) {
|
||||||
let s = ns.substring(with: m.range(at: 0))
|
let s = ns.substring(with: m.range(at: 0))
|
||||||
results.append("lightning:\(s)")
|
results.append("lightning:\(s)")
|
||||||
if results.count >= max { return results }
|
if results.count >= max { return results }
|
||||||
|
|||||||
@@ -13,18 +13,23 @@ struct MyQRView: View {
|
|||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
private var boxColor: Color { Color.gray.opacity(0.1) }
|
private var boxColor: Color { Color.gray.opacity(0.1) }
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let title: LocalizedStringKey = "verification.my_qr.title"
|
||||||
|
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", comment: "Accessibility label describing the verification QR code")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Text("scan to verify me")
|
Text(Strings.title)
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
|
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
QRCodeImage(data: qrString, size: 240)
|
QRCodeImage(data: qrString, size: 240)
|
||||||
.accessibilityLabel("verification qr code")
|
.accessibilityLabel(Strings.accessibilityLabel)
|
||||||
|
|
||||||
// Non-scrolling, fully visible URL (wraps across lines)
|
// Non-scrolling, fully visible URL (wraps across lines)
|
||||||
Text(qrString)
|
Text(qrString)
|
||||||
.font(.system(size: 11, design: .monospaced))
|
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
.multilineTextAlignment(.leading)
|
.multilineTextAlignment(.leading)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -49,6 +54,10 @@ struct QRCodeImage: View {
|
|||||||
private let context = CIContext()
|
private let context = CIContext()
|
||||||
private let filter = CIFilter.qrCodeGenerator()
|
private let filter = CIFilter.qrCodeGenerator()
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let image = generateImage() {
|
if let image = generateImage() {
|
||||||
@@ -59,8 +68,8 @@ struct QRCodeImage: View {
|
|||||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||||
.frame(width: size, height: size)
|
.frame(width: size, height: size)
|
||||||
.overlay(
|
.overlay(
|
||||||
Text("qr unavailable")
|
Text(Strings.unavailable)
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
.foregroundColor(.gray)
|
.foregroundColor(.gray)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,17 +109,41 @@ struct ImageWrapper: View {
|
|||||||
struct QRScanView: View {
|
struct QRScanView: View {
|
||||||
@EnvironmentObject var viewModel: ChatViewModel
|
@EnvironmentObject var viewModel: ChatViewModel
|
||||||
var isActive: Bool = true
|
var isActive: Bool = true
|
||||||
|
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
||||||
@State private var input = ""
|
@State private var input = ""
|
||||||
@State private var result: String = "" // not shown for iOS scanner
|
@State private var result: String = "" // not shown for iOS scanner
|
||||||
@State private var lastValid: String = ""
|
@State private var lastValid: String = ""
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
||||||
|
static let validate: LocalizedStringKey = "verification.scan.validate"
|
||||||
|
static func requested(_ nickname: String) -> String {
|
||||||
|
String(
|
||||||
|
format: String(localized: "verification.scan.status.requested", comment: "Status text when verification is requested for a nickname"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
|
||||||
|
static let invalid = String(localized: "verification.scan.status.invalid", comment: "Status when a scanned QR payload is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
CameraScannerView(isActive: isActive) { code in
|
CameraScannerView(isActive: isActive) { code in
|
||||||
|
// Deduplicate: ignore if we just processed this exact QR code
|
||||||
|
guard code != lastValid else { return }
|
||||||
|
|
||||||
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
if let qr = VerificationService.shared.verifyScannedQR(code) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
if !ok { /* already pending; continue scanning */ }
|
if ok {
|
||||||
lastValid = code
|
// Successfully initiated verification; remember this QR to prevent re-scanning
|
||||||
|
lastValid = code
|
||||||
|
// Close scanner and return to "My QR" view
|
||||||
|
onSuccess?()
|
||||||
|
}
|
||||||
|
// If !ok, peer not found or already pending - don't set lastValid so user can retry
|
||||||
} else {
|
} else {
|
||||||
// ignore invalid reads; continue scanning
|
// ignore invalid reads; continue scanning
|
||||||
}
|
}
|
||||||
@@ -118,17 +151,30 @@ struct QRScanView: View {
|
|||||||
.frame(height: 260)
|
.frame(height: 260)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
#else
|
#else
|
||||||
Text("paste qr content to validate:")
|
Text(Strings.pastePrompt)
|
||||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
|
||||||
TextEditor(text: $input)
|
TextEditor(text: $input)
|
||||||
.frame(height: 100)
|
.frame(height: 100)
|
||||||
.border(Color.gray.opacity(0.4))
|
.border(Color.gray.opacity(0.4))
|
||||||
Button("validate") {
|
Button(Strings.validate) {
|
||||||
|
// Deduplicate: ignore if we just processed this exact QR
|
||||||
|
guard input != lastValid else {
|
||||||
|
result = Strings.requested("") // Already processed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
if let qr = VerificationService.shared.verifyScannedQR(input) {
|
||||||
let ok = viewModel.beginQRVerification(with: qr)
|
let ok = viewModel.beginQRVerification(with: qr)
|
||||||
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
|
if ok {
|
||||||
|
result = Strings.requested(qr.nickname)
|
||||||
|
lastValid = input
|
||||||
|
// Close scanner and return to "My QR" view
|
||||||
|
onSuccess?()
|
||||||
|
} else {
|
||||||
|
result = Strings.notFound
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result = "invalid or expired qr payload"
|
result = Strings.invalid
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
@@ -254,8 +300,8 @@ struct VerificationSheetView: View {
|
|||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Top header (always at top)
|
// Top header (always at top)
|
||||||
HStack {
|
HStack {
|
||||||
Text("VERIFY")
|
Text("verification.sheet.title")
|
||||||
.font(.system(size: 14, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(action: {
|
Button(action: {
|
||||||
@@ -263,7 +309,7 @@ struct VerificationSheetView: View {
|
|||||||
isPresented = false
|
isPresented = false
|
||||||
}) {
|
}) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
.font(.system(size: 14, weight: .semibold))
|
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
@@ -278,18 +324,22 @@ struct VerificationSheetView: View {
|
|||||||
Group {
|
Group {
|
||||||
if showingScanner {
|
if showingScanner {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Text("scan a friend's qr")
|
Text("verification.scan.prompt_friend")
|
||||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.foregroundColor(accentColor)
|
.foregroundColor(accentColor)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
QRScanView(isActive: showingScanner)
|
QRScanView(isActive: showingScanner, onSuccess: {
|
||||||
|
showingScanner = false
|
||||||
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
.frame(height: 280)
|
.frame(height: 280)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
#else
|
#else
|
||||||
QRScanView()
|
QRScanView(onSuccess: {
|
||||||
|
showingScanner = false
|
||||||
|
})
|
||||||
.environmentObject(viewModel)
|
.environmentObject(viewModel)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -310,13 +360,13 @@ struct VerificationSheetView: View {
|
|||||||
if showingScanner {
|
if showingScanner {
|
||||||
Button(action: { showingScanner = false }) {
|
Button(action: { showingScanner = false }) {
|
||||||
Label("show my qr", systemImage: "qrcode")
|
Label("show my qr", systemImage: "qrcode")
|
||||||
.font(.system(size: 13, design: .monospaced))
|
.font(.bitchatSystem(size: 13, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
} else {
|
} else {
|
||||||
Button(action: { showingScanner = true }) {
|
Button(action: { showingScanner = true }) {
|
||||||
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
||||||
.font(.system(size: 13, weight: .medium, design: .monospaced))
|
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
@@ -328,7 +378,7 @@ struct VerificationSheetView: View {
|
|||||||
viewModel.verifiedFingerprints.contains(fp) {
|
viewModel.verifiedFingerprints.contains(fp) {
|
||||||
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
|
||||||
Label("remove verification", systemImage: "minus.circle")
|
Label("remove verification", systemImage: "minus.circle")
|
||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||||
}
|
}
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.tint(.gray)
|
.tint(.gray)
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
//
|
||||||
|
// BitchatMessage+Preview.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension BitchatMessage {
|
||||||
|
static var preview: BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: "John Doe",
|
||||||
|
content: "Hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: "Jane Doe",
|
||||||
|
senderPeerID: nil,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// PreviewKeychainManager.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class PreviewKeychainManager: KeychainManagerProtocol {
|
||||||
|
private var storage: [String: Data] = [:]
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
storage[key] = keyData
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIdentityKey(forKey key: String) -> Data? {
|
||||||
|
storage[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
|
storage.removeValue(forKey: key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteAllKeychainData() -> Bool {
|
||||||
|
storage.removeAll()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func secureClear(_ data: inout Data) {}
|
||||||
|
|
||||||
|
func secureClear(_ string: inout String) {}
|
||||||
|
|
||||||
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
storage["identity_noiseStaticKey"] != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,666 @@
|
|||||||
|
{
|
||||||
|
"sourceLanguage": "en",
|
||||||
|
"strings": {
|
||||||
|
"share.fallback.shared_link_title": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "shared Link",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "رابط مشترك",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "geteilter link",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "enlace compartido",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "lien partagé",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "קישור משותף",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "tautan dibagikan",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "link condiviso",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "共有リンク",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "साझा गरिएको लिङ्क",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "link compartilhado",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "поделился ссылкой",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "спільне посилання",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "分享的链接",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "공유된 링크",
|
||||||
|
"comment": "Fallback title when saving a shared link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.failed_to_encode": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "failed to encode link",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "تعذر ترميز الرابط",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "link konnte nicht codiert werden",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "no se pudo codificar el enlace",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "échec de l'encodage du lien",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "לא ניתן לקודד את הקישור",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "gagal mengodekan tautan",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "impossibile codificare il link",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リンクのエンコードに失敗しました",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "लिङ्क सङ्केत गर्न सकेन",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "falha ao codificar link",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "не удалось закодировать ссылку",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "не вдалося закодувати посилання",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "无法编码链接",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "링크를 인코딩하는 데 실패했습니다",
|
||||||
|
"comment": "Shown when the share payload cannot be encoded"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.no_shareable_content": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "no shareable content",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "لا محتوى قابلاً للمشاركة",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "kein teilbarer inhalt",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "sin contenido que se pueda compartir",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "aucun contenu partageable",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "אין תוכן שניתן לשתף",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "tidak ada konten yang bisa dibagikan",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nessun contenuto condivisibile",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "共有可能なコンテンツがありません",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "बाँड्न मिल्ने सामग्री छैन",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nenhum conteúdo compartilhável",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "нет подходящего контента",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "нема відповідного контенту",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "没有可分享的素材",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "공유할 수 있는 내용이 없습니다",
|
||||||
|
"comment": "Shown when provided content cannot be shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.nothing_to_share": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nothing to share",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "لا شيء لمشاركته",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nichts zum teilen",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nada que compartir",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "rien à partager",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "אין מה לשתף",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "tidak ada yang bisa dibagikan",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "niente da condividere",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "共有できるものがありません",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "बाँड्ने केही छैन",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "nada para compartilhar",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "нечем поделиться",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "нема чим ділитися",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "没有可分享的内容",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "공유할 내용이 없습니다",
|
||||||
|
"comment": "Shown when the share extension receives no content"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.shared_link": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ shared link to bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ تم إرسال الرابط إلى bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ link zu bitchat geteilt",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ enlace compartido con bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ lien partagé vers bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ הקישור נשלח אל bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ tautan dikirim ke bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ link inviato a bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchatにリンクを共有",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat मा लिङ्क पठाइयो",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ link enviado para bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ ссылка отправлена в bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ посилання надіслано в bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ 已将链接分享至 bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat으로 링크를 공유했습니다",
|
||||||
|
"comment": "Confirmation after successfully sharing a link"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"share.status.shared_text": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ shared text to bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ تم إرسال النص إلى bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ text zu bitchat geteilt",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ texto compartido con bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ texte partagé vers bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ הטקסט נשלח אל bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ teks dikirim ke bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ testo inviato a bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchatにテキストを共有",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat मा पाठ पठाइयो",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ texto enviado para bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ текст отправлен в bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ текст надіслано в bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ 已将文本分享至 bitchat",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||||
|
"comment": "Confirmation after successfully sharing text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"version": "1.0"
|
||||||
|
}
|
||||||
@@ -15,6 +15,15 @@ final class ShareViewController: UIViewController {
|
|||||||
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
// Bundle.main.bundleIdentifier would get the extension's bundleID
|
||||||
private static let groupID = "group.chat.bitchat"
|
private static let groupID = "group.chat.bitchat"
|
||||||
|
|
||||||
|
private enum Strings {
|
||||||
|
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||||
|
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||||
|
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||||
|
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
||||||
|
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
||||||
|
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
||||||
|
}
|
||||||
|
|
||||||
private let statusLabel: UILabel = {
|
private let statusLabel: UILabel = {
|
||||||
let l = UILabel()
|
let l = UILabel()
|
||||||
l.translatesAutoresizingMaskIntoConstraints = false
|
l.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@@ -44,7 +53,7 @@ final class ShareViewController: UIViewController {
|
|||||||
private func processShare() {
|
private func processShare() {
|
||||||
guard let ctx = self.extensionContext,
|
guard let ctx = self.extensionContext,
|
||||||
let item = ctx.inputItems.first as? NSExtensionItem else {
|
let item = ctx.inputItems.first as? NSExtensionItem else {
|
||||||
finishWithMessage("Nothing to share")
|
finishWithMessage(Strings.nothingToShare)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +70,7 @@ final class ShareViewController: UIViewController {
|
|||||||
if let title = item.attributedTitle?.string, !title.isEmpty {
|
if let title = item.attributedTitle?.string, !title.isEmpty {
|
||||||
saveAndFinish(text: title)
|
saveAndFinish(text: title)
|
||||||
} else {
|
} else {
|
||||||
finishWithMessage("No shareable content")
|
finishWithMessage(Strings.noShareableContent)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -81,7 +90,7 @@ final class ShareViewController: UIViewController {
|
|||||||
self.saveAndFinish(text: t)
|
self.saveAndFinish(text: t)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
self.finishWithMessage("No shareable content")
|
self.finishWithMessage(Strings.noShareableContent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,20 +145,20 @@ final class ShareViewController: UIViewController {
|
|||||||
private func saveAndFinish(url: URL, title: String?) {
|
private func saveAndFinish(url: URL, title: String?) {
|
||||||
let payload: [String: String] = [
|
let payload: [String: String] = [
|
||||||
"url": url.absoluteString,
|
"url": url.absoluteString,
|
||||||
"title": title ?? url.host ?? "Shared Link"
|
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||||
]
|
]
|
||||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||||
let s = String(data: json, encoding: .utf8) {
|
let s = String(data: json, encoding: .utf8) {
|
||||||
saveToSharedDefaults(content: s, type: "url")
|
saveToSharedDefaults(content: s, type: "url")
|
||||||
finishWithMessage("✓ Shared link to bitchat")
|
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||||
} else {
|
} else {
|
||||||
finishWithMessage("Failed to encode link")
|
finishWithMessage(Strings.failedToEncode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveAndFinish(text: String) {
|
private func saveAndFinish(text: String) {
|
||||||
saveToSharedDefaults(content: text, type: "text")
|
saveToSharedDefaults(content: text, type: "text")
|
||||||
finishWithMessage("✓ Shared text to bitchat")
|
finishWithMessage(Strings.sharedTextConfirmation)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveToSharedDefaults(content: String, type: String) {
|
private func saveToSharedDefaults(content: String, type: String) {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "REMOTE123",
|
senderPeerID: "REMOTE123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "PACKET123",
|
senderPeerID: "PACKET123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ final class BLEServiceTests: XCTestCase {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: false,
|
isPrivate: false,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeer: "TEST123",
|
senderPeerID: "TEST123",
|
||||||
mentions: nil
|
mentions: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -281,9 +281,12 @@ private final class MockBitchatDelegate: BitchatDelegate {
|
|||||||
messageHandler(message)
|
messageHandler(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didConnectToPeer(_ peerID: String) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: String) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [String]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
func isFavorite(fingerprint: String) -> Bool { return false }
|
func isFavorite(fingerprint: String) -> Bool { return false }
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,115 +6,109 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class PrivateChatE2ETests: XCTestCase {
|
// TODO: Remove once MockBLEService is refactored to fix race condition
|
||||||
|
@Suite(.serialized)
|
||||||
|
struct PrivateChatE2ETests {
|
||||||
|
|
||||||
var alice: MockBluetoothMeshService!
|
private let alice: MockBLEService
|
||||||
var bob: MockBluetoothMeshService!
|
private let bob: MockBLEService
|
||||||
var charlie: MockBluetoothMeshService!
|
private let charlie: MockBLEService
|
||||||
|
private let mockKeychain: MockKeychain
|
||||||
|
|
||||||
private var mockKeychain: MockKeychain!
|
init() {
|
||||||
|
// Create services with unique peer IDs to avoid any collision
|
||||||
override func setUp() {
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
super.setUp()
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
MockBLEService.resetTestBus()
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
|
|
||||||
// Create services
|
|
||||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
|
||||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
|
||||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// Delivery tracking is now handled internally by BLEService
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
alice = nil
|
|
||||||
bob = nil
|
|
||||||
charlie = nil
|
|
||||||
mockKeychain = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Private Messaging Tests
|
// MARK: - Basic Private Messaging Tests
|
||||||
|
|
||||||
func testSimplePrivateMessage() {
|
@Test func simplePrivateMessageShouldNotBeSentWithoutConnection() async {
|
||||||
simulateConnection(alice, bob)
|
// Intentionally not connecting alice and bob to test
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Bob receives private message")
|
var bobReceivedMessage = false
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Bob should not receive a private message", expectedCount: 0) { bobReceivesMessage in
|
||||||
if message.content == TestConstants.testMessage1 &&
|
bob.messageDeliveryHandler = { message in
|
||||||
message.isPrivate &&
|
if message.content == TestConstants.testMessage1 &&
|
||||||
message.sender == TestConstants.testNickname1 {
|
message.isPrivate &&
|
||||||
expectation.fulfill()
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
bobReceivedMessage = true
|
||||||
|
bobReceivesMessage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alice sends private message to Bob
|
||||||
|
alice.sendPrivateMessage(
|
||||||
|
TestConstants.testMessage1,
|
||||||
|
to: bob.peerID,
|
||||||
|
recipientNickname: TestConstants.testNickname2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wait a bit to ensure message would have been delivered if it was going to be
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(TestConstants.shortTimeout * 1_000_000_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends private message to Bob
|
#expect(!bobReceivedMessage, "Bob should not have received the message")
|
||||||
alice.sendPrivateMessage(
|
|
||||||
TestConstants.testMessage1,
|
|
||||||
to: TestConstants.testPeerID2,
|
|
||||||
recipientNickname: TestConstants.testNickname2
|
|
||||||
)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPrivateMessageNotReceivedByOthers() {
|
@Test func simplePrivateMessage() async {
|
||||||
// Connect all three
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, bob)
|
|
||||||
simulateConnection(alice, charlie)
|
|
||||||
|
|
||||||
let bobExpectation = XCTestExpectation(description: "Bob receives private message")
|
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||||
let charlieExpectation = XCTestExpectation(description: "Charlie should not receive")
|
bob.messageDeliveryHandler = { message in
|
||||||
charlieExpectation.isInverted = true
|
if message.content == TestConstants.testMessage1 &&
|
||||||
|
message.isPrivate &&
|
||||||
bob.messageDeliveryHandler = { message in
|
message.sender == TestConstants.testNickname1 {
|
||||||
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
bobReceivesMessage()
|
||||||
bobExpectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
// Alice sends private message to Bob
|
||||||
if message.content == TestConstants.testMessage1 {
|
alice.sendPrivateMessage(
|
||||||
charlieExpectation.fulfill() // Should not happen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to ensure connections are registered before send
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
||||||
// Alice sends private message to Bob only
|
|
||||||
self.alice.sendPrivateMessage(
|
|
||||||
TestConstants.testMessage1,
|
TestConstants.testMessage1,
|
||||||
to: TestConstants.testPeerID2,
|
to: bob.peerID,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Delivery Acknowledgment Tests
|
@Test func privateMessageNotReceivedByOthers() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
|
alice.simulateConnection(with: charlie)
|
||||||
|
|
||||||
// NOTE: DeliveryTracker has been removed in BLEService.
|
await confirmation("Bob receives private message") { bobReceivesMessage in
|
||||||
// Delivery tracking is now handled internally.
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 && message.isPrivate {
|
||||||
|
bobReceivesMessage()
|
||||||
// MARK: - Message Retry Tests
|
}
|
||||||
|
}
|
||||||
// NOTE: MessageRetryService has been removed in BLEService.
|
|
||||||
// Retry logic is now handled internally.
|
|
||||||
|
|
||||||
|
charlie.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 {
|
||||||
|
Issue.record("Charlie should not receive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.sendPrivateMessage(
|
||||||
|
TestConstants.testMessage1,
|
||||||
|
to: bob.peerID,
|
||||||
|
recipientNickname: TestConstants.testNickname2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - End-to-End Encryption Tests
|
// MARK: - End-to-End Encryption Tests
|
||||||
|
|
||||||
func testPrivateMessageEncryption() {
|
@Test func privateMessageEncryption() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
// Setup Noise sessions
|
// Setup Noise sessions
|
||||||
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
let aliceKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
@@ -125,175 +119,144 @@ final class PrivateChatE2ETests: XCTestCase {
|
|||||||
|
|
||||||
// Establish encrypted session
|
// Establish encrypted session
|
||||||
do {
|
do {
|
||||||
let handshake1 = try aliceManager.initiateHandshake(with: TestConstants.testPeerID2)
|
let handshake1 = try aliceManager.initiateHandshake(with: bob.peerID)
|
||||||
let handshake2 = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake1)!
|
let handshake2 = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake1)!
|
||||||
let handshake3 = try aliceManager.handleIncomingHandshake(from: TestConstants.testPeerID2, message: handshake2)!
|
let handshake3 = try aliceManager.handleIncomingHandshake(from: bob.peerID, message: handshake2)!
|
||||||
_ = try bobManager.handleIncomingHandshake(from: TestConstants.testPeerID1, message: handshake3)
|
_ = try bobManager.handleIncomingHandshake(from: alice.peerID, message: handshake3)
|
||||||
} catch {
|
} catch {
|
||||||
XCTFail("Failed to establish Noise session: \(error)")
|
Issue.record("Failed to establish Noise session: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Encrypted message received")
|
await confirmation("Encrypted message received") { receiveEncryptedMessage in
|
||||||
|
// Setup packet handlers for encryption
|
||||||
// Setup packet handlers for encryption
|
alice.packetDeliveryHandler = { packet in
|
||||||
alice.packetDeliveryHandler = { packet in
|
// Encrypt outgoing private messages
|
||||||
// Encrypt outgoing private messages
|
if packet.type == 0x01,
|
||||||
if packet.type == 0x01,
|
let message = BitchatMessage(packet.payload),
|
||||||
let message = BitchatMessage(packet.payload),
|
message.isPrivate {
|
||||||
message.isPrivate {
|
do {
|
||||||
do {
|
let encrypted = try aliceManager.encrypt(packet.payload, for: bob.peerID)
|
||||||
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
|
let encryptedPacket = BitchatPacket(
|
||||||
let encryptedPacket = BitchatPacket(
|
type: 0x02, // Encrypted message type
|
||||||
type: 0x02, // Encrypted message type
|
senderID: packet.senderID,
|
||||||
senderID: packet.senderID,
|
recipientID: packet.recipientID,
|
||||||
recipientID: packet.recipientID,
|
timestamp: packet.timestamp,
|
||||||
timestamp: packet.timestamp,
|
payload: encrypted,
|
||||||
payload: encrypted,
|
signature: packet.signature,
|
||||||
signature: packet.signature,
|
ttl: packet.ttl
|
||||||
ttl: packet.ttl
|
)
|
||||||
)
|
self.bob.simulateIncomingPacket(encryptedPacket)
|
||||||
self.bob.simulateIncomingPacket(encryptedPacket)
|
} catch {
|
||||||
} catch {
|
Issue.record("Encryption failed: \(error)")
|
||||||
XCTFail("Encryption failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bob.packetDeliveryHandler = { packet in
|
|
||||||
// Decrypt incoming encrypted messages
|
|
||||||
if packet.type == 0x02 {
|
|
||||||
do {
|
|
||||||
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
|
|
||||||
if let message = BitchatMessage(decrypted) {
|
|
||||||
XCTAssertEqual(message.content, TestConstants.testMessage1)
|
|
||||||
XCTAssertTrue(message.isPrivate)
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
XCTFail("Decryption failed: \(error)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send encrypted private message
|
bob.packetDeliveryHandler = { packet in
|
||||||
alice.sendPrivateMessage(
|
// Decrypt incoming encrypted messages
|
||||||
TestConstants.testMessage1,
|
if packet.type == 0x02 {
|
||||||
to: TestConstants.testPeerID2,
|
do {
|
||||||
recipientNickname: TestConstants.testNickname2
|
let decrypted = try bobManager.decrypt(packet.payload, from: alice.peerID)
|
||||||
)
|
if let message = BitchatMessage(decrypted) {
|
||||||
|
#expect(message.content == TestConstants.testMessage1)
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
#expect(message.isPrivate)
|
||||||
}
|
receiveEncryptedMessage()
|
||||||
|
}
|
||||||
// MARK: - Multi-hop Private Message Tests
|
} catch {
|
||||||
|
Issue.record("Decryption failed: \(error)")
|
||||||
func testPrivateMessageRelay() {
|
}
|
||||||
// Setup: Alice -> Bob -> Charlie
|
|
||||||
simulateConnection(alice, bob)
|
|
||||||
simulateConnection(bob, charlie)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Private message relayed to Charlie")
|
|
||||||
|
|
||||||
// Bob relays private messages for Charlie
|
|
||||||
bob.packetDeliveryHandler = { packet in
|
|
||||||
if let recipientID = packet.recipientID,
|
|
||||||
String(data: recipientID, encoding: .utf8) == TestConstants.testPeerID3 {
|
|
||||||
// Relay to Charlie
|
|
||||||
var relayPacket = packet
|
|
||||||
relayPacket.ttl = packet.ttl - 1
|
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
|
||||||
if message.content == TestConstants.testMessage1 &&
|
|
||||||
message.isPrivate &&
|
|
||||||
message.recipientNickname == TestConstants.testNickname3 {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alice sends private message to Charlie (through Bob)
|
|
||||||
alice.sendPrivateMessage(
|
|
||||||
TestConstants.testMessage1,
|
|
||||||
to: TestConstants.testPeerID3,
|
|
||||||
recipientNickname: TestConstants.testNickname3
|
|
||||||
)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Performance Tests
|
|
||||||
|
|
||||||
func testPrivateMessageThroughput() {
|
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
|
||||||
let messageCount = 100
|
|
||||||
var receivedCount = 0
|
|
||||||
let expectation = XCTestExpectation(description: "All private messages received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
|
||||||
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
|
||||||
receivedCount += 1
|
|
||||||
if receivedCount == messageCount {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send many private messages
|
// Send encrypted private message
|
||||||
for i in 0..<messageCount {
|
|
||||||
alice.sendPrivateMessage(
|
alice.sendPrivateMessage(
|
||||||
"Private message \(i)",
|
TestConstants.testMessage1,
|
||||||
to: TestConstants.testPeerID2,
|
to: TestConstants.testPeerID2,
|
||||||
recipientNickname: TestConstants.testNickname2
|
recipientNickname: TestConstants.testNickname2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertEqual(receivedCount, messageCount)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testLargePrivateMessage() {
|
// MARK: - Multi-hop Private Message Tests
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Large private message received")
|
@Test func privateMessageRelay() async {
|
||||||
|
// Setup: Alice -> Bob -> Charlie
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
|
bob.simulateConnection(with: charlie)
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Private message relayed to Charlie") { charlieReceivesMessage in
|
||||||
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
// Bob relays private messages for Charlie
|
||||||
expectation.fulfill()
|
bob.packetDeliveryHandler = { packet in
|
||||||
|
if let recipientID = packet.recipientID,
|
||||||
|
String(data: recipientID, encoding: .utf8) == charlie.peerID {
|
||||||
|
// Relay to Charlie
|
||||||
|
var relayPacket = packet
|
||||||
|
relayPacket.ttl = packet.ttl - 1
|
||||||
|
self.charlie.simulateIncomingPacket(relayPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
charlie.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 &&
|
||||||
|
message.isPrivate &&
|
||||||
|
message.recipientNickname == TestConstants.testNickname3 {
|
||||||
|
charlieReceivesMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice sends private message to Charlie (through Bob)
|
||||||
|
alice.sendPrivateMessage(
|
||||||
|
TestConstants.testMessage1,
|
||||||
|
to: charlie.peerID,
|
||||||
|
recipientNickname: TestConstants.testNickname3
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance Tests
|
||||||
|
|
||||||
|
@Test func privateMessageThroughput() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
|
let messageCount = 100
|
||||||
|
var receivedCount = 0
|
||||||
|
|
||||||
|
await confirmation("All private messages received") { receivePrivateMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.isPrivate && message.sender == TestConstants.testNickname1 {
|
||||||
|
receivedCount += 1
|
||||||
|
if receivedCount == messageCount {
|
||||||
|
receivePrivateMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send many private messages
|
||||||
|
for i in 0..<messageCount {
|
||||||
|
alice.sendPrivateMessage(
|
||||||
|
"Private message \(i)",
|
||||||
|
to: TestConstants.testPeerID2,
|
||||||
|
recipientNickname: TestConstants.testNickname2
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
alice.sendPrivateMessage(
|
|
||||||
TestConstants.testLongMessage,
|
|
||||||
to: TestConstants.testPeerID2,
|
|
||||||
recipientNickname: TestConstants.testNickname2
|
|
||||||
)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Error Handling Tests
|
@Test func largePrivateMessage() async {
|
||||||
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
// NOTE: This test relied on MessageRetryService which has been removed
|
await confirmation("Large private message received") { receiveLargeMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testLongMessage && message.isPrivate {
|
||||||
|
receiveLargeMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testDuplicateAckPrevention() throws {
|
alice.sendPrivateMessage(
|
||||||
throw XCTSkip("DeliveryTracker/ACK flow removed; test not applicable")
|
TestConstants.testLongMessage,
|
||||||
}
|
to: TestConstants.testPeerID2,
|
||||||
|
recipientNickname: TestConstants.testNickname2
|
||||||
// MARK: - Helper Methods
|
)
|
||||||
|
}
|
||||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
|
||||||
let service = MockBluetoothMeshService()
|
|
||||||
service.myPeerID = peerID
|
|
||||||
service.mockNickname = nickname
|
|
||||||
service._testRegister()
|
|
||||||
return service
|
|
||||||
}
|
|
||||||
|
|
||||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
|
||||||
peer1.simulateConnectedPeer(peer2.peerID)
|
|
||||||
peer2.simulateConnectedPeer(peer1.peerID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,440 +6,389 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import struct Foundation.UUID
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class PublicChatE2ETests: XCTestCase {
|
@Suite(.serialized)
|
||||||
|
struct PublicChatE2ETests {
|
||||||
|
|
||||||
var alice: MockBluetoothMeshService!
|
private let alice: MockBLEService
|
||||||
var bob: MockBluetoothMeshService!
|
private let bob: MockBLEService
|
||||||
var charlie: MockBluetoothMeshService!
|
private let charlie: MockBLEService
|
||||||
var david: MockBluetoothMeshService!
|
private let david: MockBLEService
|
||||||
|
|
||||||
var receivedMessages: [String: [BitchatMessage]] = [:]
|
private var receivedMessages: [String: [BitchatMessage]] = [:]
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
// Create mock services with unique peer IDs to avoid any collision
|
||||||
MockBLEService.resetTestBus()
|
alice = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname1)
|
||||||
|
bob = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname2)
|
||||||
// Create mock services
|
charlie = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname3)
|
||||||
alice = createMockService(peerID: TestConstants.testPeerID1, nickname: TestConstants.testNickname1)
|
david = MockBLEService(peerID: PeerID(str: UUID().uuidString), nickname: TestConstants.testNickname4)
|
||||||
bob = createMockService(peerID: TestConstants.testPeerID2, nickname: TestConstants.testNickname2)
|
|
||||||
charlie = createMockService(peerID: TestConstants.testPeerID3, nickname: TestConstants.testNickname3)
|
|
||||||
david = createMockService(peerID: TestConstants.testPeerID4, nickname: TestConstants.testNickname4)
|
|
||||||
|
|
||||||
// Clear received messages
|
|
||||||
receivedMessages.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tearDown() {
|
|
||||||
alice = nil
|
|
||||||
bob = nil
|
|
||||||
charlie = nil
|
|
||||||
david = nil
|
|
||||||
super.tearDown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Basic Broadcasting Tests
|
// MARK: - Basic Broadcasting Tests
|
||||||
|
|
||||||
func testSimplePublicMessage() {
|
@Test func simplePublicMessage() async {
|
||||||
// Connect Alice and Bob
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Bob receives message")
|
await confirmation("Bob receives message") { bobReceivesMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
bob.messageDeliveryHandler = { message in
|
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
||||||
if message.content == TestConstants.testMessage1 && message.sender == TestConstants.testNickname1 {
|
bobReceivesMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends public message
|
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultiRecipientBroadcast() {
|
@Test func multiRecipientBroadcast() async {
|
||||||
// Connect Alice to Bob and Charlie
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: charlie)
|
||||||
simulateConnection(alice, charlie)
|
|
||||||
|
|
||||||
let bobExpectation = XCTestExpectation(description: "Bob receives message")
|
var bobReceivedMessage = false
|
||||||
let charlieExpectation = XCTestExpectation(description: "Charlie receives message")
|
var charlieReceivedMessage = false
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Both recieve message", expectedCount: 2) { receiveMessage in
|
||||||
if message.content == TestConstants.testMessage1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
bobExpectation.fulfill()
|
if message.content == TestConstants.testMessage1 {
|
||||||
|
if !bobReceivedMessage {
|
||||||
|
bobReceivedMessage = true
|
||||||
|
receiveMessage()
|
||||||
|
} else {
|
||||||
|
Issue.record("Bob received more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
charlie.messageDeliveryHandler = { message in
|
||||||
if message.content == TestConstants.testMessage1 {
|
if message.content == TestConstants.testMessage1 {
|
||||||
charlieExpectation.fulfill()
|
if !charlieReceivedMessage {
|
||||||
|
charlieReceivedMessage = true
|
||||||
|
receiveMessage()
|
||||||
|
} else {
|
||||||
|
Issue.record("Charlie received more than once")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice broadcasts
|
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [bobExpectation, charlieExpectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Routing and Relay Tests
|
// MARK: - Message Routing and Relay Tests
|
||||||
|
|
||||||
func testMessageRelayChain() {
|
@Test func messageRelayChain() async {
|
||||||
// Linear topology: Alice -> Bob -> Charlie
|
// Linear topology: Alice -> Bob -> Charlie
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Charlie receives relayed message")
|
await confirmation("Charlie receives relayed message") { charlieReceivesMessage in
|
||||||
|
// Set up relay in Bob
|
||||||
|
bob.packetDeliveryHandler = { packet in
|
||||||
|
// Bob should relay to Charlie
|
||||||
|
if let message = BitchatMessage(packet.payload),
|
||||||
|
message.sender == TestConstants.testNickname1 {
|
||||||
|
|
||||||
// Set up relay in Bob
|
// Create relay message
|
||||||
bob.packetDeliveryHandler = { packet in
|
let relayMessage = BitchatMessage(
|
||||||
// Bob should relay to Charlie
|
id: message.id,
|
||||||
if let message = BitchatMessage(packet.payload),
|
sender: message.sender,
|
||||||
message.sender == TestConstants.testNickname1 {
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
// Create relay message
|
isRelay: true,
|
||||||
let relayMessage = BitchatMessage(
|
originalSender: message.sender,
|
||||||
id: message.id,
|
isPrivate: message.isPrivate,
|
||||||
sender: message.sender,
|
recipientNickname: message.recipientNickname,
|
||||||
content: message.content,
|
senderPeerID: message.senderPeerID,
|
||||||
timestamp: message.timestamp,
|
mentions: message.mentions
|
||||||
isRelay: true,
|
|
||||||
originalSender: message.sender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeer?.id,
|
|
||||||
mentions: message.mentions
|
|
||||||
)
|
|
||||||
|
|
||||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
|
||||||
let relayPacket = BitchatPacket(
|
|
||||||
type: packet.type,
|
|
||||||
senderID: packet.senderID,
|
|
||||||
recipientID: packet.recipientID,
|
|
||||||
timestamp: packet.timestamp,
|
|
||||||
payload: relayPayload,
|
|
||||||
signature: packet.signature,
|
|
||||||
ttl: packet.ttl - 1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simulate relay to Charlie
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
self.charlie.simulateIncomingPacket(relayPacket)
|
let relayPacket = BitchatPacket(
|
||||||
|
type: packet.type,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: packet.recipientID,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: relayPayload,
|
||||||
|
signature: packet.signature,
|
||||||
|
ttl: packet.ttl - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// Simulate relay to Charlie
|
||||||
|
self.charlie.simulateIncomingPacket(relayPacket)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
charlie.messageDeliveryHandler = { message in
|
||||||
if message.content == TestConstants.testMessage1 &&
|
if message.content == TestConstants.testMessage1 &&
|
||||||
message.originalSender == TestConstants.testNickname1 &&
|
message.originalSender == TestConstants.testNickname1 &&
|
||||||
message.isRelay {
|
message.isRelay {
|
||||||
expectation.fulfill()
|
charlieReceivesMessage()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice sends message
|
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMultiHopRelay() {
|
@Test func multiHopRelay() async {
|
||||||
// Topology: Alice -> Bob -> Charlie -> David
|
// Topology: Alice -> Bob -> Charlie -> David
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
simulateConnection(charlie, david)
|
charlie.simulateConnection(with: david)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "David receives multi-hop message")
|
await confirmation("David receives multi-hop message") { davidReceivesMessage in
|
||||||
|
// Set up relay chain
|
||||||
|
setupRelayHandler(bob, nextHops: [charlie])
|
||||||
|
setupRelayHandler(charlie, nextHops: [david])
|
||||||
|
|
||||||
// Set up relay chain
|
david.messageDeliveryHandler = { message in
|
||||||
setupRelayHandler(bob, nextHops: [charlie])
|
if message.content == TestConstants.testMessage1 &&
|
||||||
setupRelayHandler(charlie, nextHops: [david])
|
message.originalSender == TestConstants.testNickname1 &&
|
||||||
// Allow handlers to install
|
message.isRelay {
|
||||||
let sendDelay = DispatchTime.now() + 0.05
|
davidReceivesMessage()
|
||||||
|
}
|
||||||
david.messageDeliveryHandler = { message in
|
|
||||||
if message.content == TestConstants.testMessage1 &&
|
|
||||||
message.originalSender == TestConstants.testNickname1 &&
|
|
||||||
message.isRelay {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Alice sends message
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
DispatchQueue.main.asyncAfter(deadline: sendDelay) {
|
|
||||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TTL (Time To Live) Tests
|
// MARK: - TTL (Time To Live) Tests
|
||||||
|
|
||||||
func testTTLDecrement() {
|
@Test func ttlDecrement() async {
|
||||||
// Create a chain longer than TTL
|
// Create a chain longer than TTL
|
||||||
let nodes = [alice!, bob!, charlie!, david!]
|
let nodes = [alice, bob, charlie, david]
|
||||||
|
|
||||||
// Connect in chain
|
// Connect in chain
|
||||||
for i in 0..<nodes.count-1 {
|
for i in 0..<nodes.count-1 {
|
||||||
simulateConnection(nodes[i], nodes[i+1])
|
nodes[i].simulateConnection(with: nodes[i+1])
|
||||||
if i > 0 && i < nodes.count-1 {
|
if i > 0 && i < nodes.count-1 {
|
||||||
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
setupRelayHandler(nodes[i], nextHops: [nodes[i+1]])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Message dropped due to TTL")
|
await confirmation("Message dropped due to TTL", expectedCount: 0) { receiveMessage in
|
||||||
expectation.isInverted = true // Should NOT be fulfilled
|
david.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 {
|
||||||
|
receiveMessage() // This should not happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
david.messageDeliveryHandler = { message in
|
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
||||||
if message.content == TestConstants.testMessage1 {
|
let msg = TestHelpers.createTestMessage(
|
||||||
expectation.fulfill() // This should not happen
|
content: TestConstants.testMessage1,
|
||||||
|
sender: TestConstants.testNickname1,
|
||||||
|
senderPeerID: alice.peerID
|
||||||
|
)
|
||||||
|
|
||||||
|
if let payload = msg.toBinaryPayload() {
|
||||||
|
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
||||||
|
bob.simulateIncomingPacket(pkt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject at Bob with TTL=2 so Charlie sees it (TTL->1) and does not relay to David
|
|
||||||
let msg = TestHelpers.createTestMessage(content: TestConstants.testMessage1, sender: TestConstants.testNickname1, senderPeerID: alice.peerID)
|
|
||||||
if let payload = msg.toBinaryPayload() {
|
|
||||||
let pkt = TestHelpers.createTestPacket(senderID: alice.peerID, payload: payload, ttl: 2)
|
|
||||||
bob.simulateIncomingPacket(pkt)
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testZeroTTLNotRelayed() {
|
@Test func zeroTTLNotRelayed() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Zero TTL message not relayed")
|
await confirmation("Zero TTL message not relayed", expectedCount: 0) { receiveMessage in
|
||||||
expectation.isInverted = true
|
charlie.messageDeliveryHandler = { message in
|
||||||
|
if message.content == "Zero TTL message" {
|
||||||
|
receiveMessage() // Should not happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
// Create packet with TTL=0
|
||||||
if message.content == "Zero TTL message" {
|
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
||||||
expectation.fulfill() // Should not happen
|
if let payload = message.toBinaryPayload() {
|
||||||
|
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
||||||
|
alice.simulateIncomingPacket(packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create packet with TTL=0
|
|
||||||
let message = TestHelpers.createTestMessage(content: "Zero TTL message")
|
|
||||||
if let payload = message.toBinaryPayload() {
|
|
||||||
let packet = TestHelpers.createTestPacket(payload: payload, ttl: 0)
|
|
||||||
alice.simulateIncomingPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.shortTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Duplicate Detection Tests
|
// MARK: - Duplicate Detection Tests
|
||||||
|
|
||||||
func testDuplicateMessagePrevention() {
|
@Test func duplicateMessagePrevention() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
var messageCount = 0
|
var messageCount = 0
|
||||||
let expectation = XCTestExpectation(description: "Only one message received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Only one message received") { receiveMessage in
|
||||||
if message.content == TestConstants.testMessage1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
messageCount += 1
|
if message.content == TestConstants.testMessage1 {
|
||||||
if messageCount == 1 {
|
receiveMessage()
|
||||||
// Send duplicate after small delay
|
messageCount += 1
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
if messageCount == 1 {
|
||||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
// Send duplicate after small delay
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil, messageID: message.id)
|
||||||
|
} else {
|
||||||
|
Issue.record("Duplicate message was not filtered")
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
XCTFail("Duplicate message was not filtered")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send original message
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send original message
|
@Test func duplicateContentAsNewMessageNotPrevented() async {
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
// Wait to ensure duplicate would have been received
|
var messageCount = 0
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
XCTAssertEqual(messageCount, 1)
|
await confirmation("Only one message received", expectedCount: 2) { receiveMessage in
|
||||||
expectation.fulfill()
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testMessage1 {
|
||||||
|
receiveMessage()
|
||||||
|
messageCount += 1
|
||||||
|
if messageCount == 1 {
|
||||||
|
// Send the same content as a new message
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send original message
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mention Tests
|
// MARK: - Mention Tests
|
||||||
|
|
||||||
func testMessageWithMentions() {
|
@Test func messageWithMentions() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(alice, charlie)
|
alice.simulateConnection(with: charlie)
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Mentioned users receive notification")
|
|
||||||
|
|
||||||
var mentionedUsers: Set<String> = []
|
var mentionedUsers: Set<String> = []
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("Mentioned users receive notification", expectedCount: 2) { receiveMention in
|
||||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname2) {
|
bob.messageDeliveryHandler = { message in
|
||||||
mentionedUsers.insert(TestConstants.testNickname2)
|
if message.mentions?.contains(TestConstants.testNickname2) == true {
|
||||||
|
mentionedUsers.insert(TestConstants.testNickname2)
|
||||||
|
receiveMention()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
charlie.messageDeliveryHandler = { message in
|
charlie.messageDeliveryHandler = { message in
|
||||||
if let mentions = message.mentions, mentions.contains(TestConstants.testNickname3) {
|
if message.mentions?.contains(TestConstants.testNickname3) == true {
|
||||||
mentionedUsers.insert(TestConstants.testNickname3)
|
mentionedUsers.insert(TestConstants.testNickname3)
|
||||||
|
receiveMention()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alice mentions Bob and Charlie
|
||||||
|
alice.sendMessage(
|
||||||
|
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
||||||
|
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
||||||
|
to: nil
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice mentions Bob and Charlie
|
#expect(mentionedUsers == [TestConstants.testNickname2, TestConstants.testNickname3])
|
||||||
alice.sendMessage(
|
|
||||||
"Hey @\(TestConstants.testNickname2) and @\(TestConstants.testNickname3)!",
|
|
||||||
mentions: [TestConstants.testNickname2, TestConstants.testNickname3],
|
|
||||||
to: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
XCTAssertEqual(mentionedUsers, [TestConstants.testNickname2, TestConstants.testNickname3])
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Network Topology Tests
|
// MARK: - Network Topology Tests
|
||||||
|
|
||||||
func testMeshTopologyBroadcast() {
|
@Test func meshTopologyBroadcast() async {
|
||||||
// Create mesh: Everyone connected to everyone
|
// Create mesh: Everyone connected to everyone
|
||||||
let nodes = [alice!, bob!, charlie!, david!]
|
let nodes = [alice, bob, charlie, david]
|
||||||
for i in 0..<nodes.count {
|
for i in 0..<nodes.count {
|
||||||
for j in i+1..<nodes.count {
|
for j in i+1..<nodes.count {
|
||||||
simulateConnection(nodes[i], nodes[j])
|
nodes[i].simulateConnection(with: nodes[j])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var receivedCount = 0
|
await confirmation("All nodes receive message", expectedCount: 3) { receiveMessage in
|
||||||
let expectation = XCTestExpectation(description: "All nodes receive message")
|
for (index, node) in nodes.enumerated() where index > 0 {
|
||||||
|
node.messageDeliveryHandler = { message in
|
||||||
for (index, node) in nodes.enumerated() where index > 0 {
|
if message.content == TestConstants.testMessage1 {
|
||||||
node.messageDeliveryHandler = { message in
|
receiveMessage()
|
||||||
if message.content == TestConstants.testMessage1 {
|
|
||||||
receivedCount += 1
|
|
||||||
if receivedCount == 3 { // Bob, Charlie, David
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alice broadcasts
|
|
||||||
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func testPartialMeshRelay() {
|
@Test func partialMeshRelay() async {
|
||||||
// Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice
|
// Partial mesh: Alice -> Bob, Bob -> Charlie, Charlie -> David, David -> Alice
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
simulateConnection(bob, charlie)
|
bob.simulateConnection(with: charlie)
|
||||||
simulateConnection(charlie, david)
|
charlie.simulateConnection(with: david)
|
||||||
simulateConnection(david, alice)
|
david.simulateConnection(with: alice)
|
||||||
|
|
||||||
// Setup relay handlers
|
// Setup relay handlers
|
||||||
setupRelayHandler(bob, nextHops: [charlie])
|
setupRelayHandler(bob, nextHops: [charlie])
|
||||||
setupRelayHandler(charlie, nextHops: [david])
|
setupRelayHandler(charlie, nextHops: [david])
|
||||||
setupRelayHandler(david, nextHops: [alice])
|
setupRelayHandler(david, nextHops: [alice])
|
||||||
let sendDelay2 = DispatchTime.now() + 0.05
|
|
||||||
|
|
||||||
var receivedCount = 0
|
await confirmation("Message reaches all nodes once", expectedCount: 3) { receiveMessage in
|
||||||
let expectation = XCTestExpectation(description: "Message reaches all nodes once")
|
for node in [bob, charlie, david] {
|
||||||
|
node.messageDeliveryHandler = { message in
|
||||||
let checkCompletion = {
|
if message.content == TestConstants.testMessage1 {
|
||||||
if receivedCount == 3 { // Bob, Charlie, David should receive
|
receiveMessage()
|
||||||
expectation.fulfill()
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for node in [bob!, charlie!, david!] {
|
|
||||||
node.messageDeliveryHandler = { message in
|
|
||||||
if message.content == TestConstants.testMessage1 {
|
|
||||||
receivedCount += 1
|
|
||||||
checkCompletion()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Alice broadcasts
|
alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
||||||
DispatchQueue.main.asyncAfter(deadline: sendDelay2) {
|
|
||||||
self.alice.sendMessage(TestConstants.testMessage1, mentions: [], to: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Performance and Stress Tests
|
// MARK: - Performance and Stress Tests
|
||||||
|
|
||||||
func testHighVolumeMessaging() {
|
@Test func highVolumeMessaging() async {
|
||||||
simulateConnection(alice, bob)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
let messageCount = 100
|
let messageCount = 100
|
||||||
var receivedCount = 0
|
|
||||||
let expectation = XCTestExpectation(description: "All messages received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
await confirmation("All messages received", expectedCount: messageCount) { receiveMessage in
|
||||||
if message.sender == TestConstants.testNickname1 {
|
bob.messageDeliveryHandler = { message in
|
||||||
receivedCount += 1
|
if message.sender == TestConstants.testNickname1 {
|
||||||
if receivedCount == messageCount {
|
receiveMessage()
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send many messages rapidly
|
// Send many messages rapidly
|
||||||
for i in 0..<messageCount {
|
for i in 0..<messageCount {
|
||||||
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
alice.sendMessage("Message \(i)", mentions: [], to: nil)
|
||||||
}
|
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.longTimeout)
|
|
||||||
XCTAssertEqual(receivedCount, messageCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testLargeMessageBroadcast() {
|
|
||||||
simulateConnection(alice, bob)
|
|
||||||
|
|
||||||
let expectation = XCTestExpectation(description: "Large message received")
|
|
||||||
|
|
||||||
bob.messageDeliveryHandler = { message in
|
|
||||||
if message.content == TestConstants.testLongMessage {
|
|
||||||
expectation.fulfill()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Send large message
|
@Test func largeMessageBroadcast() async {
|
||||||
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
alice.simulateConnection(with: bob)
|
||||||
|
|
||||||
wait(for: [expectation], timeout: TestConstants.defaultTimeout)
|
await confirmation("Large message received") { receiveLargeMessage in
|
||||||
|
bob.messageDeliveryHandler = { message in
|
||||||
|
if message.content == TestConstants.testLongMessage {
|
||||||
|
receiveLargeMessage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
alice.sendMessage(TestConstants.testLongMessage, mentions: [], to: nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createMockService(peerID: String, nickname: String) -> MockBluetoothMeshService {
|
private func setupRelayHandler(_ node: MockBLEService, nextHops: [MockBLEService]) {
|
||||||
let service = MockBluetoothMeshService()
|
|
||||||
service.myPeerID = peerID
|
|
||||||
service.mockNickname = nickname
|
|
||||||
service._testRegister()
|
|
||||||
return service
|
|
||||||
}
|
|
||||||
|
|
||||||
private func simulateConnection(_ peer1: MockBluetoothMeshService, _ peer2: MockBluetoothMeshService) {
|
|
||||||
peer1.simulateConnectedPeer(peer2.peerID)
|
|
||||||
peer2.simulateConnectedPeer(peer1.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupRelayHandler(_ node: MockBluetoothMeshService, nextHops: [MockBluetoothMeshService]) {
|
|
||||||
node.packetDeliveryHandler = { packet in
|
node.packetDeliveryHandler = { packet in
|
||||||
// Check if should relay
|
// Check if should relay
|
||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
// Don't relay own messages
|
// Don't relay own messages
|
||||||
guard message.senderPeer?.id != node.peerID else { return }
|
guard message.senderPeerID?.id != node.peerID else { return }
|
||||||
|
|
||||||
// Create relay message
|
// Create relay message
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
@@ -451,14 +400,14 @@ final class PublicChatE2ETests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeer?.id,
|
senderPeerID: message.senderPeerID,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
if let relayPayload = relayMessage.toBinaryPayload() {
|
if let relayPayload = relayMessage.toBinaryPayload() {
|
||||||
let relayPacket = BitchatPacket(
|
let relayPacket = BitchatPacket(
|
||||||
type: packet.type,
|
type: packet.type,
|
||||||
senderID: node.peerID.data(using: .utf8)!,
|
senderID: node.peerID.id.data(using: .utf8)!,
|
||||||
recipientID: packet.recipientID,
|
recipientID: packet.recipientID,
|
||||||
timestamp: packet.timestamp,
|
timestamp: packet.timestamp,
|
||||||
payload: relayPayload,
|
payload: relayPayload,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class FontBitchatTests: XCTestCase {
|
||||||
|
// func testMonospacedMapping() {
|
||||||
|
// XCTAssertEqual(Font.bitchatSystem(size: 10, design: .monospaced), Font.system(.caption2, design: .monospaced))
|
||||||
|
// XCTAssertEqual(Font.bitchatSystem(size: 14, design: .monospaced), Font.system(.body, design: .monospaced))
|
||||||
|
// XCTAssertEqual(Font.bitchatSystem(size: 20, design: .monospaced), Font.system(.title2, design: .monospaced))
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testWeightIsPreserved() {
|
||||||
|
// let bold = Font.bitchatSystem(size: 14, weight: .bold, design: .monospaced)
|
||||||
|
// XCTAssertEqual(bold, Font.system(.body, design: .monospaced).weight(.bold))
|
||||||
|
// }
|
||||||
|
}
|
||||||
@@ -6,48 +6,149 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import CoreBluetooth
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class FragmentationTests: XCTestCase {
|
struct FragmentationTests {
|
||||||
|
|
||||||
private var mockKeychain: MockKeychain!
|
private let mockKeychain: MockKeychain
|
||||||
private var mockIdentityManager: MockIdentityManager!
|
private let mockIdentityManager: MockIdentityManager
|
||||||
|
|
||||||
override func setUp() {
|
init() {
|
||||||
super.setUp()
|
|
||||||
mockKeychain = MockKeychain()
|
mockKeychain = MockKeychain()
|
||||||
mockIdentityManager = MockIdentityManager(mockKeychain)
|
mockIdentityManager = MockIdentityManager(mockKeychain)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
@Test("Reassembly from fragments delivers a public message")
|
||||||
mockKeychain = nil
|
func reassemblyFromFragmentsDeliversPublicMessage() async throws {
|
||||||
mockIdentityManager = nil
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
super.tearDown()
|
let capture = CaptureDelegate()
|
||||||
|
ble.delegate = capture
|
||||||
|
|
||||||
|
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
||||||
|
let remoteShortID: PeerID = "1122334455667788"
|
||||||
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
||||||
|
|
||||||
|
// Use a small fragment size to ensure multiple pieces
|
||||||
|
let fragments = fragmentPacket(original, fragmentSize: 400)
|
||||||
|
|
||||||
|
// Shuffle fragments to simulate out-of-order arrival
|
||||||
|
let shuffled = fragments.shuffled()
|
||||||
|
|
||||||
|
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
||||||
|
for (i, fragment) in shuffled.enumerated() {
|
||||||
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
|
Task {
|
||||||
|
try await Task.sleep(nanoseconds: delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
|
#expect(capture.publicMessages.count == 1)
|
||||||
|
#expect(capture.publicMessages.first?.content.count == 3_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Duplicate fragment does not break reassembly")
|
||||||
|
func duplicateFragmentDoesNotBreakReassembly() async throws {
|
||||||
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
|
let capture = CaptureDelegate()
|
||||||
|
ble.delegate = capture
|
||||||
|
|
||||||
|
let remoteShortID: PeerID = "A1B2C3D4E5F60708"
|
||||||
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
||||||
|
var frags = fragmentPacket(original, fragmentSize: 300)
|
||||||
|
|
||||||
|
// Duplicate one fragment
|
||||||
|
if let dup = frags.first {
|
||||||
|
frags.insert(dup, at: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, fragment) in frags.enumerated() {
|
||||||
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
|
Task {
|
||||||
|
try await Task.sleep(nanoseconds: delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
|
#expect(capture.publicMessages.count == 1)
|
||||||
|
#expect(capture.publicMessages.first?.content.count == 2048)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Invalid fragment header is ignored")
|
||||||
|
func invalidFragmentHeaderIsIgnored() async throws {
|
||||||
|
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
||||||
|
let capture = CaptureDelegate()
|
||||||
|
ble.delegate = capture
|
||||||
|
|
||||||
|
let remoteShortID: PeerID = "0011223344556677"
|
||||||
|
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
||||||
|
let fragments = fragmentPacket(original, fragmentSize: 250)
|
||||||
|
|
||||||
|
// Corrupt one fragment: make payload too short (header incomplete)
|
||||||
|
var corrupted = fragments
|
||||||
|
if !corrupted.isEmpty {
|
||||||
|
var p = corrupted[0]
|
||||||
|
p = BitchatPacket(
|
||||||
|
type: p.type,
|
||||||
|
senderID: p.senderID,
|
||||||
|
recipientID: p.recipientID,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
payload: Data([0x00, 0x01, 0x02]), // invalid header
|
||||||
|
signature: nil,
|
||||||
|
ttl: p.ttl
|
||||||
|
)
|
||||||
|
corrupted[0] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, fragment) in corrupted.enumerated() {
|
||||||
|
let delay = UInt64(5 * i) * 1_000_000 // nanoseconds
|
||||||
|
Task {
|
||||||
|
try await Task.sleep(nanoseconds: delay)
|
||||||
|
ble._test_handlePacket(fragment, fromPeerID: remoteShortID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow async processing
|
||||||
|
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s
|
||||||
|
|
||||||
|
// Should not deliver since one fragment is invalid and reassembly can't complete
|
||||||
|
#expect(capture.publicMessages.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FragmentationTests {
|
||||||
private final class CaptureDelegate: BitchatDelegate {
|
private final class CaptureDelegate: BitchatDelegate {
|
||||||
var publicMessages: [(peerID: String, nickname: String, content: String)] = []
|
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] = []
|
||||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||||
func didConnectToPeer(_ peerID: String) {}
|
func didConnectToPeer(_ peerID: PeerID) {}
|
||||||
func didDisconnectFromPeer(_ peerID: String) {}
|
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||||
func didUpdatePeerList(_ peers: [String]) {}
|
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||||
func isFavorite(fingerprint: String) -> Bool { false }
|
func isFavorite(fingerprint: String) -> Bool { false }
|
||||||
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {}
|
||||||
func didReceiveNoisePayload(from peerID: String, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {}
|
||||||
func didReceivePublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {
|
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||||
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {
|
||||||
publicMessages.append((peerID, nickname, content))
|
publicMessages.append((peerID, nickname, content))
|
||||||
}
|
}
|
||||||
func didReceiveRegionalPublicMessage(from peerID: String, nickname: String, content: String, timestamp: Date) {}
|
func didReceiveRegionalPublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: build a large message packet (unencrypted public message)
|
// Helper: build a large message packet (unencrypted public message)
|
||||||
private func makeLargePublicPacket(senderShortHex: String, size: Int) -> BitchatPacket {
|
private func makeLargePublicPacket(senderShortHex: PeerID, size: Int) -> BitchatPacket {
|
||||||
let content = String(repeating: "A", count: size)
|
let content = String(repeating: "A", count: size)
|
||||||
let payload = Data(content.utf8)
|
let payload = Data(content.utf8)
|
||||||
let pkt = BitchatPacket(
|
let pkt = BitchatPacket(
|
||||||
type: MessageType.message.rawValue,
|
type: MessageType.message.rawValue,
|
||||||
senderID: Data(hexString: senderShortHex) ?? Data(),
|
senderID: Data(hexString: senderShortHex.id) ?? Data(),
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -88,101 +189,4 @@ final class FragmentationTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
return packets
|
return packets
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_reassembly_from_fragments_delivers_public_message() {
|
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
|
||||||
let capture = CaptureDelegate()
|
|
||||||
ble.delegate = capture
|
|
||||||
|
|
||||||
// Construct a big packet (3KB) from a remote sender (not our own ID)
|
|
||||||
let remoteShortID = "1122334455667788"
|
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 3_000)
|
|
||||||
// Use a small fragment size to ensure multiple pieces
|
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 400)
|
|
||||||
|
|
||||||
// Shuffle fragments to simulate out-of-order arrival
|
|
||||||
let shuffled = fragments.shuffled()
|
|
||||||
|
|
||||||
// Inject fragments spaced out to avoid concurrent mutation inside BLEService
|
|
||||||
for (i, f) in shuffled.enumerated() {
|
|
||||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
|
||||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow async processing
|
|
||||||
let exp = expectation(description: "reassembled")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
|
||||||
wait(for: [exp], timeout: 2.0)
|
|
||||||
|
|
||||||
XCTAssertEqual(capture.publicMessages.count, 1)
|
|
||||||
XCTAssertEqual(capture.publicMessages.first?.content.count, 3_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_duplicate_fragment_does_not_break_reassembly() {
|
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
|
||||||
let capture = CaptureDelegate()
|
|
||||||
ble.delegate = capture
|
|
||||||
|
|
||||||
let remoteShortID = "A1B2C3D4E5F60708"
|
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 2048)
|
|
||||||
var frags = fragmentPacket(original, fragmentSize: 300)
|
|
||||||
// Duplicate one fragment
|
|
||||||
if let dup = frags.first { frags.insert(dup, at: 1) }
|
|
||||||
|
|
||||||
for (i, f) in frags.enumerated() {
|
|
||||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
|
||||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let exp = expectation(description: "reassembled2")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
|
||||||
wait(for: [exp], timeout: 2.0)
|
|
||||||
|
|
||||||
XCTAssertEqual(capture.publicMessages.count, 1)
|
|
||||||
XCTAssertEqual(capture.publicMessages.first?.content.count, 2048)
|
|
||||||
}
|
|
||||||
|
|
||||||
func test_invalid_fragment_header_is_ignored() {
|
|
||||||
let ble = BLEService(keychain: mockKeychain, identityManager: mockIdentityManager)
|
|
||||||
let capture = CaptureDelegate()
|
|
||||||
ble.delegate = capture
|
|
||||||
|
|
||||||
let remoteShortID = "0011223344556677"
|
|
||||||
let original = makeLargePublicPacket(senderShortHex: remoteShortID, size: 1000)
|
|
||||||
let fragments = fragmentPacket(original, fragmentSize: 250)
|
|
||||||
|
|
||||||
// Corrupt one fragment: make payload too short (header incomplete)
|
|
||||||
var corrupted = fragments
|
|
||||||
if !corrupted.isEmpty {
|
|
||||||
var p = corrupted[0]
|
|
||||||
p = BitchatPacket(
|
|
||||||
type: p.type,
|
|
||||||
senderID: p.senderID,
|
|
||||||
recipientID: p.recipientID,
|
|
||||||
timestamp: p.timestamp,
|
|
||||||
payload: Data([0x00, 0x01, 0x02]), // invalid header
|
|
||||||
signature: nil,
|
|
||||||
ttl: p.ttl
|
|
||||||
)
|
|
||||||
corrupted[0] = p
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, f) in corrupted.enumerated() {
|
|
||||||
let delay = DispatchTime.now() + .milliseconds(5 * i)
|
|
||||||
DispatchQueue.global().asyncAfter(deadline: delay) {
|
|
||||||
ble._test_handlePacket(f, fromPeerID: remoteShortID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let exp = expectation(description: "no reassembly")
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exp.fulfill() }
|
|
||||||
wait(for: [exp], timeout: 2.0)
|
|
||||||
|
|
||||||
// Should not deliver since one fragment is invalid and reassembly can't complete
|
|
||||||
XCTAssertEqual(capture.publicMessages.count, 0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class GCSFilterTests: XCTestCase {
|
||||||
|
func testBuildFilterWithDuplicateIdsProducesStableEncoding() {
|
||||||
|
let id = Data(repeating: 0xAB, count: 16)
|
||||||
|
let ids = Array(repeating: id, count: 64)
|
||||||
|
|
||||||
|
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 128, targetFpr: 0.01)
|
||||||
|
XCTAssertGreaterThanOrEqual(params.m, 1)
|
||||||
|
|
||||||
|
let decoded = GCSFilter.decodeToSortedSet(p: params.p, m: params.m, data: params.data)
|
||||||
|
XCTAssertLessThanOrEqual(decoded.count, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testBucketAvoidsZeroCandidate() {
|
||||||
|
let id = Data(repeating: 0x01, count: 16)
|
||||||
|
let bucket = GCSFilter.bucket(for: id, modulus: 2)
|
||||||
|
XCTAssertNotEqual(bucket, 0)
|
||||||
|
XCTAssertLessThan(bucket, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,23 +3,25 @@ import XCTest
|
|||||||
|
|
||||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||||
let storeKey = "locationChannel.bookmarks"
|
let storeKey = "locationChannel.bookmarks"
|
||||||
|
var storage: UserDefaults!
|
||||||
|
var store: GeohashBookmarksStore!
|
||||||
|
|
||||||
override func setUp() {
|
override func setUp() {
|
||||||
super.setUp()
|
super.setUp()
|
||||||
// Clear persisted state before each test
|
// Unique instance for each test to avoid race condition
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
storage = UserDefaults(suiteName: UUID().uuidString)
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
store = GeohashBookmarksStore(storage: storage!)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func tearDown() {
|
override func tearDown() {
|
||||||
// Clean after each test
|
storage.removeObject(forKey: storeKey)
|
||||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
store._resetForTesting()
|
||||||
GeohashBookmarksStore.shared._resetForTesting()
|
store = nil
|
||||||
|
storage = nil
|
||||||
super.tearDown()
|
super.tearDown()
|
||||||
}
|
}
|
||||||
|
|
||||||
func testToggleAndNormalize() {
|
func testToggleAndNormalize() {
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
// Start clean
|
// Start clean
|
||||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||||
|
|
||||||
@@ -35,12 +37,11 @@ final class GeohashBookmarksStoreTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPersistenceWritten() throws {
|
func testPersistenceWritten() throws {
|
||||||
let store = GeohashBookmarksStore.shared
|
|
||||||
store.toggle("ezs42")
|
store.toggle("ezs42")
|
||||||
store.toggle("u4pruy")
|
store.toggle("u4pruy")
|
||||||
|
|
||||||
// Verify persisted JSON contains both (order not enforced here)
|
// Verify persisted JSON contains both (order not enforced here)
|
||||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
guard let data = storage.data(forKey: storeKey) else {
|
||||||
XCTFail("No persisted data found")
|
XCTFail("No persisted data found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
final class GossipSyncManagerTests: XCTestCase {
|
||||||
|
func testConcurrentPacketIntakeAndSyncRequest() {
|
||||||
|
let manager = GossipSyncManager(myPeerID: "0102030405060708")
|
||||||
|
let delegate = RecordingDelegate()
|
||||||
|
let sendExpectation = expectation(description: "sync request sent")
|
||||||
|
delegate.onSend = { sendExpectation.fulfill() }
|
||||||
|
manager.delegate = delegate
|
||||||
|
|
||||||
|
let iterations = 200
|
||||||
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
for i in 0..<iterations {
|
||||||
|
group.enter()
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: Data(hexString: "1122334455667788") ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: 1_000_000 + UInt64(i),
|
||||||
|
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
manager.onPublicPacketSeen(packet)
|
||||||
|
Thread.sleep(forTimeInterval: 0.001)
|
||||||
|
group.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.002) {
|
||||||
|
manager.scheduleInitialSyncToPeer("FFFFFFFFFFFFFFFF", delaySeconds: 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
group.wait()
|
||||||
|
wait(for: [sendExpectation], timeout: 2.0)
|
||||||
|
|
||||||
|
guard let lastPacket = delegate.lastPacket else {
|
||||||
|
XCTFail("Expected sync packet to be sent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
XCTAssertEqual(lastPacket.type, MessageType.requestSync.rawValue)
|
||||||
|
XCTAssertNotNil(RequestSyncPacket.decode(from: lastPacket.payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
|
var onSend: (() -> Void)?
|
||||||
|
private(set) var lastPacket: BitchatPacket?
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
func sendPacket(_ packet: BitchatPacket) {
|
||||||
|
lock.lock()
|
||||||
|
lastPacket = packet
|
||||||
|
lock.unlock()
|
||||||
|
onSend?()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPacket(to peerID: PeerID, packet: BitchatPacket) {
|
||||||
|
sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import CryptoKit
|
|||||||
|
|
||||||
final class IntegrationTests: XCTestCase {
|
final class IntegrationTests: XCTestCase {
|
||||||
|
|
||||||
var nodes: [String: MockBluetoothMeshService] = [:]
|
var nodes: [String: MockBLEService] = [:]
|
||||||
var noiseManagers: [String: NoiseSessionManager] = [:]
|
var noiseManagers: [String: NoiseSessionManager] = [:]
|
||||||
private var mockKeychain: MockKeychain!
|
private var mockKeychain: MockKeychain!
|
||||||
|
|
||||||
@@ -587,11 +587,10 @@ final class IntegrationTests: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|
||||||
private func createNode(_ name: String, peerID: String) {
|
private func createNode(_ name: String, peerID: PeerID) {
|
||||||
let node = MockBluetoothMeshService()
|
let node = MockBLEService()
|
||||||
node.myPeerID = peerID
|
node.myPeerID = peerID
|
||||||
node.mockNickname = name
|
node.mockNickname = name
|
||||||
node._testRegister()
|
|
||||||
nodes[name] = node
|
nodes[name] = node
|
||||||
|
|
||||||
// Create Noise manager
|
// Create Noise manager
|
||||||
@@ -627,7 +626,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
guard packet.ttl > 1 else { return }
|
guard packet.ttl > 1 else { return }
|
||||||
|
|
||||||
if let message = BitchatMessage(packet.payload) {
|
if let message = BitchatMessage(packet.payload) {
|
||||||
guard message.senderPeer?.id != node.peerID else { return }
|
guard message.senderPeerID != node.peerID else { return }
|
||||||
|
|
||||||
let relayMessage = BitchatMessage(
|
let relayMessage = BitchatMessage(
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -638,7 +637,7 @@ final class IntegrationTests: XCTestCase {
|
|||||||
originalSender: message.isRelay ? message.originalSender : message.sender,
|
originalSender: message.isRelay ? message.originalSender : message.sender,
|
||||||
isPrivate: message.isPrivate,
|
isPrivate: message.isPrivate,
|
||||||
recipientNickname: message.recipientNickname,
|
recipientNickname: message.recipientNickname,
|
||||||
senderPeerID: message.senderPeer?.id,
|
senderPeerID: message.senderPeerID,
|
||||||
mentions: message.mentions
|
mentions: message.mentions
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,450 @@
|
|||||||
|
{
|
||||||
|
"app": [
|
||||||
|
"app_info.app_name",
|
||||||
|
"app_info.close",
|
||||||
|
"app_info.done",
|
||||||
|
"app_info.features.encryption.title",
|
||||||
|
"app_info.features.offline.title",
|
||||||
|
"app_info.warning.message",
|
||||||
|
"common.cancel",
|
||||||
|
"common.close",
|
||||||
|
"common.copy",
|
||||||
|
"common.ok",
|
||||||
|
"content.accessibility.people_count",
|
||||||
|
"content.accessibility.send_message",
|
||||||
|
"content.actions.title",
|
||||||
|
"content.alert.bluetooth_required.permission",
|
||||||
|
"content.alert.bluetooth_required.settings",
|
||||||
|
"content.alert.bluetooth_required.title",
|
||||||
|
"content.delivery.delivered_to",
|
||||||
|
"content.input.message_placeholder",
|
||||||
|
"fingerprint.action.mark_verified",
|
||||||
|
"fingerprint.badge.not_verified",
|
||||||
|
"fingerprint.badge.verified",
|
||||||
|
"fingerprint.title",
|
||||||
|
"location_channels.action.open_settings",
|
||||||
|
"location_channels.action.request_permissions",
|
||||||
|
"location_channels.title",
|
||||||
|
"location_notes.header",
|
||||||
|
"system.tor.started"
|
||||||
|
],
|
||||||
|
"shareExtension": [
|
||||||
|
"share.fallback.shared_link_title",
|
||||||
|
"share.status.failed_to_encode",
|
||||||
|
"share.status.no_shareable_content",
|
||||||
|
"share.status.nothing_to_share",
|
||||||
|
"share.status.shared_link",
|
||||||
|
"share.status.shared_text"
|
||||||
|
],
|
||||||
|
"expectedValues": {
|
||||||
|
"ar": {
|
||||||
|
"common.ok": "موافق",
|
||||||
|
"content.input.message_placeholder": "اكتب رسالة...",
|
||||||
|
"location_channels.title": "#قنوات الموقع",
|
||||||
|
"system.tor.started": "tor يعمل. كل الدردشة تمر عبر tor للخصوصية.",
|
||||||
|
"share.status.shared_text": "✓ تم إرسال النص إلى bitchat",
|
||||||
|
"share.status.shared_link": "✓ تم إرسال الرابط إلى bitchat",
|
||||||
|
"share.status.failed_to_encode": "تعذر ترميز الرابط",
|
||||||
|
"common.cancel": "إلغاء",
|
||||||
|
"common.close": "إغلاق",
|
||||||
|
"common.copy": "نسخ",
|
||||||
|
"app_info.close": "إغلاق",
|
||||||
|
"app_info.done": "تم",
|
||||||
|
"content.alert.bluetooth_required.permission": "تحتاج bitchat إلى إذن bluetooth للاتصال بالأجهزة القريبة. فعّل الوصول في الإعدادات.",
|
||||||
|
"content.alert.bluetooth_required.settings": "الإعدادات",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "تشفير طرف لطرف",
|
||||||
|
"app_info.features.offline.title": "تواصل بدون اتصال",
|
||||||
|
"fingerprint.badge.verified": "✓ مُتحقق",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ غير مُتحقق",
|
||||||
|
"fingerprint.action.mark_verified": "وضع علامة تم التحقق",
|
||||||
|
"location_channels.action.open_settings": "فتح الإعدادات",
|
||||||
|
"content.actions.title": "إجراءات",
|
||||||
|
"content.accessibility.send_message": "إرسال رسالة",
|
||||||
|
"share.status.nothing_to_share": "لا شيء لمشاركته",
|
||||||
|
"share.status.no_shareable_content": "لا محتوى قابلاً للمشاركة",
|
||||||
|
"share.fallback.shared_link_title": "رابط مشترك"
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "nachricht eingeben...",
|
||||||
|
"location_channels.title": "#standort-kanäle",
|
||||||
|
"system.tor.started": "tor läuft. der gesamte chat wird über tor geleitet.",
|
||||||
|
"share.status.shared_text": "✓ text zu bitchat geteilt",
|
||||||
|
"share.status.shared_link": "✓ link zu bitchat geteilt",
|
||||||
|
"share.status.failed_to_encode": "link konnte nicht codiert werden",
|
||||||
|
"common.cancel": "abbrechen",
|
||||||
|
"common.close": "schließen",
|
||||||
|
"common.copy": "kopieren",
|
||||||
|
"app_info.close": "schließen",
|
||||||
|
"app_info.done": "FERTIG",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat benötigt bluetooth-berechtigung, um sich mit geräten in der nähe zu verbinden. erlaube den zugriff in den einstellungen.",
|
||||||
|
"content.alert.bluetooth_required.settings": "einstellungen",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "end-to-end-verschlüsselung",
|
||||||
|
"app_info.features.offline.title": "offline-kommunikation",
|
||||||
|
"fingerprint.badge.verified": "✓ VERIFIZIERT",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ NICHT VERIFIZIERT",
|
||||||
|
"fingerprint.action.mark_verified": "als verifiziert markieren",
|
||||||
|
"location_channels.action.open_settings": "einstellungen öffnen",
|
||||||
|
"content.actions.title": "aktionen",
|
||||||
|
"content.accessibility.send_message": "nachricht senden",
|
||||||
|
"share.status.nothing_to_share": "nichts zum teilen",
|
||||||
|
"share.status.no_shareable_content": "kein teilbarer inhalt",
|
||||||
|
"share.fallback.shared_link_title": "geteilter link"
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"common.ok": "aceptar",
|
||||||
|
"content.input.message_placeholder": "escribe un mensaje...",
|
||||||
|
"location_channels.title": "#canales de ubicación",
|
||||||
|
"system.tor.started": "tor se inició. Todo el chat se enruta por Tor para privacidad.",
|
||||||
|
"share.status.shared_text": "✓ texto compartido con bitchat",
|
||||||
|
"share.status.shared_link": "✓ enlace compartido con bitchat",
|
||||||
|
"share.status.failed_to_encode": "no se pudo codificar el enlace",
|
||||||
|
"common.cancel": "cancelar",
|
||||||
|
"common.close": "cerrar",
|
||||||
|
"common.copy": "copiar",
|
||||||
|
"app_info.close": "cerrar",
|
||||||
|
"app_info.done": "LISTO",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitChat necesita permiso de Bluetooth para conectarse con dispositivos cercanos. Habilita el acceso en Ajustes.",
|
||||||
|
"content.alert.bluetooth_required.settings": "ajustes",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "cifrado de extremo a extremo",
|
||||||
|
"app_info.features.offline.title": "comunicación sin conexión",
|
||||||
|
"fingerprint.badge.verified": "✓ VERIFICADO",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ NO VERIFICADO",
|
||||||
|
"fingerprint.action.mark_verified": "marcar como verificado",
|
||||||
|
"location_channels.action.open_settings": "abrir ajustes",
|
||||||
|
"content.actions.title": "acciones",
|
||||||
|
"content.accessibility.send_message": "enviar mensaje",
|
||||||
|
"share.status.nothing_to_share": "nada que compartir",
|
||||||
|
"share.status.no_shareable_content": "sin contenido que se pueda compartir",
|
||||||
|
"share.fallback.shared_link_title": "enlace compartido"
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "écris un message...",
|
||||||
|
"location_channels.title": "#canaux localisation",
|
||||||
|
"system.tor.started": "tor a démarré. tout le chat passe par tor pour la confidentialité.",
|
||||||
|
"share.status.shared_text": "✓ texte partagé vers bitchat",
|
||||||
|
"share.status.shared_link": "✓ lien partagé vers bitchat",
|
||||||
|
"share.status.failed_to_encode": "échec de l'encodage du lien",
|
||||||
|
"common.cancel": "annuler",
|
||||||
|
"common.close": "fermer",
|
||||||
|
"common.copy": "copier",
|
||||||
|
"app_info.close": "fermer",
|
||||||
|
"app_info.done": "TERMINÉ",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat a besoin de l'autorisation bluetooth pour se connecter aux appareils proches. active l'accès dans réglages.",
|
||||||
|
"content.alert.bluetooth_required.settings": "réglages",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "chiffrement de bout en bout",
|
||||||
|
"app_info.features.offline.title": "communication hors ligne",
|
||||||
|
"fingerprint.badge.verified": "✓ VÉRIFIÉ",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ NON VÉRIFIÉ",
|
||||||
|
"fingerprint.action.mark_verified": "marquer comme vérifié",
|
||||||
|
"location_channels.action.open_settings": "ouvrir réglages",
|
||||||
|
"content.actions.title": "actions",
|
||||||
|
"content.accessibility.send_message": "envoyer le message",
|
||||||
|
"share.status.nothing_to_share": "rien à partager",
|
||||||
|
"share.status.no_shareable_content": "aucun contenu partageable",
|
||||||
|
"share.fallback.shared_link_title": "lien partagé"
|
||||||
|
},
|
||||||
|
"he": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "כתוב הודעה...",
|
||||||
|
"location_channels.title": "#ערוצי מיקום",
|
||||||
|
"system.tor.started": "tor פעיל. כל הצ'אט עובר דרך tor לפרטיות.",
|
||||||
|
"share.status.shared_text": "✓ הטקסט נשלח אל bitchat",
|
||||||
|
"share.status.shared_link": "✓ הקישור נשלח אל bitchat",
|
||||||
|
"share.status.failed_to_encode": "לא ניתן לקודד את הקישור",
|
||||||
|
"common.cancel": "ביטול",
|
||||||
|
"common.close": "סגור",
|
||||||
|
"common.copy": "העתק",
|
||||||
|
"app_info.close": "סגור",
|
||||||
|
"app_info.done": "בוצע",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat צריכה הרשאת bluetooth כדי להתחבר למכשירים קרובים. אפשר גישה בהגדרות.",
|
||||||
|
"content.alert.bluetooth_required.settings": "הגדרות",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "הצפנה מקצה לקצה",
|
||||||
|
"app_info.features.offline.title": "תקשורת לא מקוונת",
|
||||||
|
"fingerprint.badge.verified": "✓ מאומת",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ לא מאומת",
|
||||||
|
"fingerprint.action.mark_verified": "סמן כמאומת",
|
||||||
|
"location_channels.action.open_settings": "פתח הגדרות",
|
||||||
|
"content.actions.title": "פעולות",
|
||||||
|
"content.accessibility.send_message": "שלח הודעה",
|
||||||
|
"share.status.nothing_to_share": "אין מה לשתף",
|
||||||
|
"share.status.no_shareable_content": "אין תוכן שניתן לשתף",
|
||||||
|
"share.fallback.shared_link_title": "קישור משותף"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "ketik pesan...",
|
||||||
|
"location_channels.title": "#kanal lokasi",
|
||||||
|
"system.tor.started": "tor berjalan. seluruh chat dirutekan lewat tor demi privasi.",
|
||||||
|
"share.status.shared_text": "✓ teks dikirim ke bitchat",
|
||||||
|
"share.status.shared_link": "✓ tautan dikirim ke bitchat",
|
||||||
|
"share.status.failed_to_encode": "gagal mengodekan tautan",
|
||||||
|
"common.cancel": "batal",
|
||||||
|
"common.close": "tutup",
|
||||||
|
"common.copy": "salin",
|
||||||
|
"app_info.close": "tutup",
|
||||||
|
"app_info.done": "SELESAI",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat memerlukan izin bluetooth untuk terhubung dengan perangkat dekat. aktifkan akses di pengaturan.",
|
||||||
|
"content.alert.bluetooth_required.settings": "pengaturan",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "enkripsi ujung ke ujung",
|
||||||
|
"app_info.features.offline.title": "komunikasi offline",
|
||||||
|
"fingerprint.badge.verified": "✓ TERVERIFIKASI",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ BELUM TERVERIFIKASI",
|
||||||
|
"fingerprint.action.mark_verified": "tandai sebagai terverifikasi",
|
||||||
|
"location_channels.action.open_settings": "buka pengaturan",
|
||||||
|
"content.actions.title": "aksi",
|
||||||
|
"content.accessibility.send_message": "kirim pesan",
|
||||||
|
"share.status.nothing_to_share": "tidak ada yang bisa dibagikan",
|
||||||
|
"share.status.no_shareable_content": "tidak ada konten yang bisa dibagikan",
|
||||||
|
"share.fallback.shared_link_title": "tautan dibagikan"
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "scrivi un messaggio...",
|
||||||
|
"location_channels.title": "#canali posizione",
|
||||||
|
"system.tor.started": "tor è avviato. tutta la chat passa da tor per la privacy.",
|
||||||
|
"share.status.shared_text": "✓ testo inviato a bitchat",
|
||||||
|
"share.status.shared_link": "✓ link inviato a bitchat",
|
||||||
|
"share.status.failed_to_encode": "impossibile codificare il link",
|
||||||
|
"common.cancel": "annulla",
|
||||||
|
"common.close": "chiudi",
|
||||||
|
"common.copy": "copia",
|
||||||
|
"app_info.close": "chiudi",
|
||||||
|
"app_info.done": "FATTO",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat richiede l'autorizzazione bluetooth per collegarsi ai dispositivi vicini. abilita l'accesso nelle impostazioni.",
|
||||||
|
"content.alert.bluetooth_required.settings": "impostazioni",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "crittografia end-to-end",
|
||||||
|
"app_info.features.offline.title": "comunicazione offline",
|
||||||
|
"fingerprint.badge.verified": "✓ VERIFICATO",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ NON VERIFICATO",
|
||||||
|
"fingerprint.action.mark_verified": "segna come verificato",
|
||||||
|
"location_channels.action.open_settings": "apri impostazioni",
|
||||||
|
"content.actions.title": "azioni",
|
||||||
|
"content.accessibility.send_message": "invia messaggio",
|
||||||
|
"share.status.nothing_to_share": "niente da condividere",
|
||||||
|
"share.status.no_shareable_content": "nessun contenuto condivisibile",
|
||||||
|
"share.fallback.shared_link_title": "link condiviso"
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "メッセージを入力...",
|
||||||
|
"location_channels.title": "#ロケーションチャンネル",
|
||||||
|
"system.tor.started": "torを起動しました。全チャットをtor経由で配信します。",
|
||||||
|
"share.status.shared_text": "✓ bitchatにテキストを共有",
|
||||||
|
"share.status.shared_link": "✓ bitchatにリンクを共有",
|
||||||
|
"share.status.failed_to_encode": "リンクのエンコードに失敗しました",
|
||||||
|
"common.cancel": "キャンセル",
|
||||||
|
"common.close": "閉じる",
|
||||||
|
"common.copy": "コピー",
|
||||||
|
"app_info.close": "閉じる",
|
||||||
|
"app_info.done": "完了",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchatは近くのデバイスと接続するためbluetooth権限が必要です。設定でアクセスを有効にしてください。",
|
||||||
|
"content.alert.bluetooth_required.settings": "設定",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "エンドツーエンド暗号",
|
||||||
|
"app_info.features.offline.title": "オフライン通信",
|
||||||
|
"fingerprint.badge.verified": "✓ 検証済み",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ 未検証",
|
||||||
|
"fingerprint.action.mark_verified": "検証済みにする",
|
||||||
|
"location_channels.action.open_settings": "設定を開く",
|
||||||
|
"content.actions.title": "アクション",
|
||||||
|
"content.accessibility.send_message": "メッセージ送信",
|
||||||
|
"share.status.nothing_to_share": "共有できるものがありません",
|
||||||
|
"share.status.no_shareable_content": "共有可能なコンテンツがありません",
|
||||||
|
"share.fallback.shared_link_title": "共有リンク"
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"common.ok": "확인",
|
||||||
|
"content.input.message_placeholder": "메시지를 입력하세요...",
|
||||||
|
"location_channels.title": "#위치 채널",
|
||||||
|
"system.tor.started": "tor가 시작되었습니다. IP 보호를 위해 모든 대화를 tor를 통해 라우팅합니다.",
|
||||||
|
"share.status.shared_text": "✓ bitchat으로 텍스트를 공유했습니다",
|
||||||
|
"share.status.shared_link": "✓ bitchat으로 링크를 공유했습니다",
|
||||||
|
"share.status.failed_to_encode": "링크를 인코딩하는 데 실패했습니다",
|
||||||
|
"common.cancel": "취소",
|
||||||
|
"common.close": "닫기",
|
||||||
|
"common.copy": "복사",
|
||||||
|
"app_info.close": "닫기",
|
||||||
|
"app_info.done": "확인",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat은 주변 기기와 연결하기 위해 bluetooth 권한이 필요합니다. 설정에서 bluetooth 접근을 활성화해주세요.",
|
||||||
|
"content.alert.bluetooth_required.settings": "설정",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "종단간 암호화",
|
||||||
|
"app_info.features.offline.title": "오프라인 통신",
|
||||||
|
"fingerprint.badge.verified": "✓ 인증됨",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ 인증되지 않음",
|
||||||
|
"fingerprint.action.mark_verified": "인증됨으로 표시",
|
||||||
|
"location_channels.action.open_settings": "설정 열기",
|
||||||
|
"content.actions.title": "작업",
|
||||||
|
"content.accessibility.send_message": "메시지 보내기",
|
||||||
|
"share.status.nothing_to_share": "공유할 내용이 없습니다",
|
||||||
|
"share.status.no_shareable_content": "공유할 수 있는 내용이 없습니다",
|
||||||
|
"share.fallback.shared_link_title": "공유된 링크"
|
||||||
|
},
|
||||||
|
"ne": {
|
||||||
|
"common.ok": "ठिक",
|
||||||
|
"content.input.message_placeholder": "सन्देश टाइप गर...",
|
||||||
|
"location_channels.title": "#स्थान च्यानल",
|
||||||
|
"system.tor.started": "tor सुरु भयो। गोपनीयताका लागि पूरा च्याट tor मार्फत जान्छ।",
|
||||||
|
"share.status.shared_text": "✓ bitchat मा पाठ पठाइयो",
|
||||||
|
"share.status.shared_link": "✓ bitchat मा लिङ्क पठाइयो",
|
||||||
|
"share.status.failed_to_encode": "लिङ्क सङ्केत गर्न सकेन",
|
||||||
|
"common.cancel": "रद्द",
|
||||||
|
"common.close": "बन्द",
|
||||||
|
"common.copy": "प्रतिलिपि",
|
||||||
|
"app_info.close": "बन्द",
|
||||||
|
"app_info.done": "सम्पन्न",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat लाई नजिकका उपकरणसँग जडान हुन bluetooth अनुमति चाहिन्छ। सेटिङमा पहुँच सक्षम गर।",
|
||||||
|
"content.alert.bluetooth_required.settings": "सेटिङ",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "एन्ड-टु-एन्ड सङ्केत",
|
||||||
|
"app_info.features.offline.title": "अफलाइन सञ्चार",
|
||||||
|
"fingerprint.badge.verified": "✓ प्रमाणित",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ प्रमाणित छैन",
|
||||||
|
"fingerprint.action.mark_verified": "प्रमाणित चिन्ह लगाउ",
|
||||||
|
"location_channels.action.open_settings": "सेटिङ खोल",
|
||||||
|
"content.actions.title": "कार्य",
|
||||||
|
"content.accessibility.send_message": "सन्देश पठाउ",
|
||||||
|
"share.status.nothing_to_share": "बाँड्ने केही छैन",
|
||||||
|
"share.status.no_shareable_content": "बाँड्न मिल्ने सामग्री छैन",
|
||||||
|
"share.fallback.shared_link_title": "साझा गरिएको लिङ्क"
|
||||||
|
},
|
||||||
|
"pt-BR": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "digite uma mensagem...",
|
||||||
|
"location_channels.title": "#canais de localização",
|
||||||
|
"system.tor.started": "tor iniciou. todo o chat é roteado por tor para privacidade.",
|
||||||
|
"share.status.shared_text": "✓ texto enviado para bitchat",
|
||||||
|
"share.status.shared_link": "✓ link enviado para bitchat",
|
||||||
|
"share.status.failed_to_encode": "falha ao codificar link",
|
||||||
|
"common.cancel": "cancelar",
|
||||||
|
"common.close": "fechar",
|
||||||
|
"common.copy": "copiar",
|
||||||
|
"app_info.close": "fechar",
|
||||||
|
"app_info.done": "CONCLUÍDO",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat precisa de permissão de bluetooth para conectar com dispositivos próximos. habilite o acesso em ajustes.",
|
||||||
|
"content.alert.bluetooth_required.settings": "ajustes",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "criptografia ponto a ponto",
|
||||||
|
"app_info.features.offline.title": "comunicação offline",
|
||||||
|
"fingerprint.badge.verified": "✓ VERIFICADO",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ NÃO VERIFICADO",
|
||||||
|
"fingerprint.action.mark_verified": "marcar como verificado",
|
||||||
|
"location_channels.action.open_settings": "abrir ajustes",
|
||||||
|
"content.actions.title": "ações",
|
||||||
|
"content.accessibility.send_message": "enviar mensagem",
|
||||||
|
"share.status.nothing_to_share": "nada para compartilhar",
|
||||||
|
"share.status.no_shareable_content": "nenhum conteúdo compartilhável",
|
||||||
|
"share.fallback.shared_link_title": "link compartilhado"
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "напиши сообщение...",
|
||||||
|
"location_channels.title": "#каналы локации",
|
||||||
|
"system.tor.started": "tor запущен. весь чат идёт через tor для приватности.",
|
||||||
|
"share.status.shared_text": "✓ текст отправлен в bitchat",
|
||||||
|
"share.status.shared_link": "✓ ссылка отправлена в bitchat",
|
||||||
|
"share.status.failed_to_encode": "не удалось закодировать ссылку",
|
||||||
|
"common.cancel": "отмена",
|
||||||
|
"common.close": "закрыть",
|
||||||
|
"common.copy": "копировать",
|
||||||
|
"app_info.close": "закрыть",
|
||||||
|
"app_info.done": "ГОТОВО",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat нужен доступ к bluetooth, чтобы соединяться с ближайшими устройствами. включи разрешение в настройках.",
|
||||||
|
"content.alert.bluetooth_required.settings": "настройки",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "сквозное шифрование",
|
||||||
|
"app_info.features.offline.title": "офлайн-связь",
|
||||||
|
"fingerprint.badge.verified": "✓ ПРОВЕРЕНО",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ НЕ ПРОВЕРЕНО",
|
||||||
|
"fingerprint.action.mark_verified": "пометить как проверено",
|
||||||
|
"location_channels.action.open_settings": "открыть настройки",
|
||||||
|
"content.actions.title": "действия",
|
||||||
|
"content.accessibility.send_message": "отправить сообщение",
|
||||||
|
"share.status.nothing_to_share": "нечем поделиться",
|
||||||
|
"share.status.no_shareable_content": "нет подходящего контента",
|
||||||
|
"share.fallback.shared_link_title": "поделился ссылкой"
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"common.ok": "OK",
|
||||||
|
"content.input.message_placeholder": "напиши повідомлення...",
|
||||||
|
"location_channels.title": "#канали локації",
|
||||||
|
"system.tor.started": "tor запущено. увесь чат іде через tor для приватності.",
|
||||||
|
"share.status.shared_text": "✓ текст надіслано в bitchat",
|
||||||
|
"share.status.shared_link": "✓ посилання надіслано в bitchat",
|
||||||
|
"share.status.failed_to_encode": "не вдалося закодувати посилання",
|
||||||
|
"common.cancel": "скасувати",
|
||||||
|
"common.close": "закрити",
|
||||||
|
"common.copy": "скопіювати",
|
||||||
|
"app_info.close": "закрити",
|
||||||
|
"app_info.done": "ГОТОВО",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat потребує дозволу bluetooth для з'єднання з пристроями поруч. ввімкни доступ у налаштуваннях.",
|
||||||
|
"content.alert.bluetooth_required.settings": "налаштування",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "скрізьове шифрування",
|
||||||
|
"app_info.features.offline.title": "офлайн-зв'язок",
|
||||||
|
"fingerprint.badge.verified": "✓ ПЕРЕВІРЕНО",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ НЕ ПЕРЕВІРЕНО",
|
||||||
|
"fingerprint.action.mark_verified": "позначити як перевірено",
|
||||||
|
"location_channels.action.open_settings": "відкрити налаштування",
|
||||||
|
"content.actions.title": "дії",
|
||||||
|
"content.accessibility.send_message": "надіслати повідомлення",
|
||||||
|
"share.status.nothing_to_share": "нема чим ділитися",
|
||||||
|
"share.status.no_shareable_content": "нема відповідного контенту",
|
||||||
|
"share.fallback.shared_link_title": "спільне посилання"
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"common.ok": "确定",
|
||||||
|
"content.input.message_placeholder": "输入消息...",
|
||||||
|
"location_channels.title": "#位置频道",
|
||||||
|
"system.tor.started": "tor 已启动。所有聊天通过 tor 路由以保护 IP。",
|
||||||
|
"share.status.shared_text": "✓ 已将文本分享至 bitchat",
|
||||||
|
"share.status.shared_link": "✓ 已将链接分享至 bitchat",
|
||||||
|
"share.status.failed_to_encode": "无法编码链接",
|
||||||
|
"common.cancel": "取消",
|
||||||
|
"common.close": "关闭",
|
||||||
|
"common.copy": "复制",
|
||||||
|
"app_info.close": "关闭",
|
||||||
|
"app_info.done": "完成",
|
||||||
|
"content.alert.bluetooth_required.permission": "bitchat 需要 bluetooth 权限以连接附近设备。请在设置中启用访问。",
|
||||||
|
"content.alert.bluetooth_required.settings": "设置",
|
||||||
|
"app_info.app_name": "bitchat",
|
||||||
|
"app_info.features.encryption.title": "端到端加密",
|
||||||
|
"app_info.features.offline.title": "离线通信",
|
||||||
|
"fingerprint.badge.verified": "✓ 已验证",
|
||||||
|
"fingerprint.badge.not_verified": "⚠️ 未验证",
|
||||||
|
"fingerprint.action.mark_verified": "标记为已验证",
|
||||||
|
"location_channels.action.open_settings": "打开设置",
|
||||||
|
"content.actions.title": "操作",
|
||||||
|
"content.accessibility.send_message": "发送消息",
|
||||||
|
"share.status.nothing_to_share": "没有可分享的内容",
|
||||||
|
"share.status.no_shareable_content": "没有可分享的素材",
|
||||||
|
"share.fallback.shared_link_title": "分享的链接"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"testLocales": [
|
||||||
|
"en",
|
||||||
|
"ar",
|
||||||
|
"de",
|
||||||
|
"es",
|
||||||
|
"fr",
|
||||||
|
"he",
|
||||||
|
"id",
|
||||||
|
"it",
|
||||||
|
"ja",
|
||||||
|
"ko",
|
||||||
|
"ne",
|
||||||
|
"pt-BR",
|
||||||
|
"ru",
|
||||||
|
"uk",
|
||||||
|
"zh-Hans"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
//import Foundation
|
||||||
|
//import XCTest
|
||||||
|
//@testable import bitchat
|
||||||
|
//
|
||||||
|
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
|
||||||
|
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
|
||||||
|
//
|
||||||
|
//final class LocalizationCatalogTests: XCTestCase {
|
||||||
|
// // Ensures every app locale includes exactly the same keys as Base.
|
||||||
|
// func testAppCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies format placeholders stay consistent across app locales.
|
||||||
|
// func testAppCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Guards a core set of app strings from going empty per locale.
|
||||||
|
// func testAppPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().app
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures every share extension locale matches Base key coverage.
|
||||||
|
// func testShareExtensionCatalogLocaleParity() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertLocaleParity(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Verifies share extension placeholders align across locales.
|
||||||
|
// func testShareExtensionCatalogPlaceholderConsistency() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Confirms critical share extension strings remain non-empty per locale.
|
||||||
|
// func testShareExtensionPrimaryKeysNonEmpty() throws {
|
||||||
|
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let primaryKeys = try loadPrimaryKeys().shareExtension
|
||||||
|
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Validates that configured locales contain expected string values.
|
||||||
|
// func testLocalizationExpectedValues() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let expectedValues = config.expectedValues else {
|
||||||
|
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Loop through each locale to test
|
||||||
|
// for locale in testLocales {
|
||||||
|
// guard let localeExpectedValues = expectedValues[locale] else {
|
||||||
|
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Test each expected key/value pair for this locale
|
||||||
|
// for (key, expectedValue) in localeExpectedValues {
|
||||||
|
// if config.app.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
|
||||||
|
// } else if config.shareExtension.contains(key) {
|
||||||
|
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Ensures configured test locales are present and complete.
|
||||||
|
// func testConfiguredLocalesCompleteness() throws {
|
||||||
|
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
|
||||||
|
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
|
||||||
|
// let config = try loadPrimaryKeys()
|
||||||
|
//
|
||||||
|
// guard let testLocales = config.testLocales else {
|
||||||
|
// // If no testLocales specified, skip this test
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// let baseLocale = appContext.baseLocale
|
||||||
|
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
|
||||||
|
//
|
||||||
|
// for locale in testLocales {
|
||||||
|
// // Skip base locale comparison with itself
|
||||||
|
// if locale == baseLocale { continue }
|
||||||
|
//
|
||||||
|
// // Verify locale is present in both catalogs
|
||||||
|
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
|
||||||
|
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
|
||||||
|
//
|
||||||
|
// // Verify locale has same number of keys as base locale
|
||||||
|
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
|
||||||
|
//
|
||||||
|
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
|
||||||
|
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Assertions
|
||||||
|
//
|
||||||
|
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseKeys = context.keysByLocale[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// let baseLocale = context.baseLocale
|
||||||
|
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
|
||||||
|
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// guard locale != baseLocale else { continue }
|
||||||
|
// for key in baseSignatures.keys.sorted() {
|
||||||
|
// guard let baseMap = baseSignatures[key] else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// guard let localeMap = localeSignatures[key] else {
|
||||||
|
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for path in baseMap.keys.sorted() {
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[path, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeMap[path, default: []])
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// for (localePath, localeTokens) in localeMap {
|
||||||
|
// guard baseMap[localePath] == nil else { continue }
|
||||||
|
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
|
||||||
|
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
|
||||||
|
// let actual = normalizedPlaceholders(localeTokens)
|
||||||
|
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// for key in keys {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// for locale in context.locales.sorted() {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// for segment in segments {
|
||||||
|
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
// guard let entry = context.catalog.strings[key] else {
|
||||||
|
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // For simple strings (non-pluralized)
|
||||||
|
// if let actualValue = unit.value {
|
||||||
|
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
|
||||||
|
// } else {
|
||||||
|
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Loading
|
||||||
|
//
|
||||||
|
// private func loadContext(relativePath: String) throws -> CatalogContext {
|
||||||
|
// let catalog = try loadCatalog(relativePath: relativePath)
|
||||||
|
// let locales = catalog.locales
|
||||||
|
// let baseLocale = catalog.sourceLanguage
|
||||||
|
// var keysByLocale: [String: Set<String>] = [:]
|
||||||
|
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
|
||||||
|
//
|
||||||
|
// for locale in locales {
|
||||||
|
// var localeKeys: Set<String> = []
|
||||||
|
// var localePlaceholders: [String: [String: [String]]] = [:]
|
||||||
|
// for (key, entry) in catalog.strings {
|
||||||
|
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// localeKeys.insert(key)
|
||||||
|
// let segments = gatherSegments(from: unit)
|
||||||
|
// var pathMap: [String: [String]] = [:]
|
||||||
|
// for segment in segments {
|
||||||
|
// pathMap[segment.path] = placeholders(in: segment.value)
|
||||||
|
// }
|
||||||
|
// localePlaceholders[key] = pathMap
|
||||||
|
// }
|
||||||
|
// keysByLocale[locale] = localeKeys
|
||||||
|
// placeholderSignature[locale] = localePlaceholders
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
|
||||||
|
// let url = repoRootURL.appendingPathComponent(relativePath)
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(StringCatalog.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
|
||||||
|
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
|
||||||
|
// let data = try Data(contentsOf: url)
|
||||||
|
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // MARK: - Regression Tests
|
||||||
|
//
|
||||||
|
// /// Helper method to access the app bundle for localization testing.
|
||||||
|
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
|
||||||
|
// /// the test bundle, preventing false positives where localization keys are
|
||||||
|
// /// returned instead of translated strings.
|
||||||
|
// private func appBundle() -> Bundle {
|
||||||
|
// Bundle(for: ChatViewModel.self)
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /// Tests to prevent regression of the pluralization format string issue
|
||||||
|
// /// These tests ensure that String(localized:) properly handles
|
||||||
|
// /// pluralized format strings with %#@variable@ syntax
|
||||||
|
// func testPluralizedFormatStringsDoNotCrash() {
|
||||||
|
// // These are the exact calls that were causing runtime errors
|
||||||
|
//
|
||||||
|
// // Test 1: content.accessibility.people_count with Int argument
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Accessibility label announcing number of people in header"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
|
||||||
|
// }(), "People count localization should not throw")
|
||||||
|
//
|
||||||
|
// // Test 2: location_notes.header with String and Int arguments
|
||||||
|
// XCTAssertNoThrow({
|
||||||
|
// let result = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Header displaying the geohash and localized note count"
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
|
||||||
|
// // Verify we're getting actual localized content, not just the key
|
||||||
|
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
|
||||||
|
// }(), "Location notes header localization should not throw")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testFormatStringArgumentMatching() {
|
||||||
|
// // Verify that the format strings properly handle their expected arguments
|
||||||
|
//
|
||||||
|
// // People count expects: %#@people@ format with Int
|
||||||
|
// let peopleResult = String(
|
||||||
|
// localized: "content.accessibility.people_count",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Should not show format specifiers in the base string
|
||||||
|
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
|
||||||
|
//
|
||||||
|
// // Location notes expects: #%@ • %#@note_count@ format with String, Int
|
||||||
|
// let notesResult = String(
|
||||||
|
// localized: "location_notes.header",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "Test"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testPluralizedStringsWithArguments() {
|
||||||
|
// // Test the pluralized strings with actual format arguments
|
||||||
|
//
|
||||||
|
// // Test people count with different values
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Just verify it doesn't crash and returns a reasonable string
|
||||||
|
// XCTAssertFalse(result.isEmpty,
|
||||||
|
// "People count should return non-empty string for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain the count number for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationNotesHeaderWithArguments() {
|
||||||
|
// let geohash = "abc123"
|
||||||
|
// let testCases = [0, 1, 2, 10]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// geohash, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(geohash),
|
||||||
|
// "Result should contain geohash for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocationChannelsRowTitleWithArguments() {
|
||||||
|
// let label = "test-channel"
|
||||||
|
// let testCases = [0, 1, 2, 5]
|
||||||
|
//
|
||||||
|
// for count in testCases {
|
||||||
|
// let result = String(
|
||||||
|
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// label, count
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// // Verify basic structure is maintained
|
||||||
|
// XCTAssertTrue(result.contains(label),
|
||||||
|
// "Result should contain label for count \(count)")
|
||||||
|
// XCTAssertTrue(result.contains("\(count)"),
|
||||||
|
// "Result should contain count for count \(count)")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testNoArgumentsLocalization() {
|
||||||
|
// // Test that strings without arguments still work
|
||||||
|
// let result = String(
|
||||||
|
// localized: "common.ok",
|
||||||
|
// bundle: appBundle(),
|
||||||
|
// comment: "OK button text"
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testLocalizationEdgeCases() {
|
||||||
|
// // Test edge cases that might cause issues
|
||||||
|
//
|
||||||
|
// // Large numbers
|
||||||
|
// let largeNumberResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 1000000
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
|
||||||
|
//
|
||||||
|
// // Zero
|
||||||
|
// let zeroResult = String(
|
||||||
|
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// 0
|
||||||
|
// )
|
||||||
|
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
|
||||||
|
//
|
||||||
|
// // Empty geohash (edge case)
|
||||||
|
// let emptyGeohashResult = String(
|
||||||
|
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
|
||||||
|
// locale: .current,
|
||||||
|
// "", 1
|
||||||
|
// )
|
||||||
|
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//// MARK: - Helpers
|
||||||
|
//
|
||||||
|
//private struct CatalogContext {
|
||||||
|
// let catalog: StringCatalog
|
||||||
|
// let locales: [String]
|
||||||
|
// let baseLocale: String
|
||||||
|
// let keysByLocale: [String: Set<String>]
|
||||||
|
// let placeholderSignature: [String: [String: [String: [String]]]]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct StringCatalog: Decodable {
|
||||||
|
// let sourceLanguage: String
|
||||||
|
// let strings: [String: CatalogEntry]
|
||||||
|
//
|
||||||
|
// var locales: [String] {
|
||||||
|
// var localeSet: Set<String> = []
|
||||||
|
// for entry in strings.values {
|
||||||
|
// localeSet.formUnion(entry.localizations.keys)
|
||||||
|
// }
|
||||||
|
// return localeSet.sorted()
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogEntry: Decodable {
|
||||||
|
// let localizations: [String: CatalogLocalization]
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogLocalization: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogStringUnit: Decodable {
|
||||||
|
// let state: String
|
||||||
|
// let value: String?
|
||||||
|
// let variations: CatalogVariations?
|
||||||
|
// let comment: String?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariations: Decodable {
|
||||||
|
// let plural: [String: [String: CatalogVariationValue]]?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct CatalogVariationValue: Decodable {
|
||||||
|
// let stringUnit: CatalogStringUnit?
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct Segment {
|
||||||
|
// let components: [String]
|
||||||
|
// let value: String
|
||||||
|
//
|
||||||
|
// var path: String {
|
||||||
|
// components.isEmpty ? "base" : components.joined(separator: ".")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
|
||||||
|
// var segments: [Segment] = []
|
||||||
|
// if let value = unit.value {
|
||||||
|
// segments.append(Segment(components: prefix, value: value))
|
||||||
|
// } else if prefix.isEmpty {
|
||||||
|
// segments.append(Segment(components: [], value: ""))
|
||||||
|
// }
|
||||||
|
// if let plural = unit.variations?.plural {
|
||||||
|
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
|
||||||
|
// if let nested = variation.stringUnit {
|
||||||
|
// var nextPrefix = prefix
|
||||||
|
// nextPrefix.append("plural")
|
||||||
|
// nextPrefix.append(variable)
|
||||||
|
// nextPrefix.append(category)
|
||||||
|
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return segments
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
|
||||||
|
// tokens.sorted()
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
|
||||||
|
// let parts = localePath.split(separator: ".")
|
||||||
|
// guard parts.count == 3, parts.first == "plural" else {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// let variable = parts[1]
|
||||||
|
// let otherKey = "plural.\(variable).other"
|
||||||
|
// if baseMap[otherKey] != nil {
|
||||||
|
// return otherKey
|
||||||
|
// }
|
||||||
|
// let oneKey = "plural.\(variable).one"
|
||||||
|
// if baseMap[oneKey] != nil {
|
||||||
|
// return oneKey
|
||||||
|
// }
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private let placeholderRegex: NSRegularExpression = {
|
||||||
|
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
|
||||||
|
// return try! NSRegularExpression(pattern: pattern, options: [])
|
||||||
|
//}()
|
||||||
|
//
|
||||||
|
//private func placeholders(in string: String) -> [String] {
|
||||||
|
// let range = NSRange(location: 0, length: (string as NSString).length)
|
||||||
|
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
|
||||||
|
// var tokens: [String] = []
|
||||||
|
// for match in matches {
|
||||||
|
// if let range = Range(match.range, in: string) {
|
||||||
|
// let token = String(string[range])
|
||||||
|
// if token == "%%" { continue }
|
||||||
|
// tokens.append(token)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// return tokens
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//private struct PrimaryKeyConfig: Decodable {
|
||||||
|
// let app: [String]
|
||||||
|
// let shareExtension: [String]
|
||||||
|
// let expectedValues: [String: [String: String]]?
|
||||||
|
// let testLocales: [String]?
|
||||||
|
//}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesManagerTests: XCTestCase {
|
||||||
|
// func testSubscribeWithoutRelaysSetsNoRelaysState() {
|
||||||
|
// var subscribeCalled = false
|
||||||
|
// let deps = LocationNotesDependencies(
|
||||||
|
// relayLookup: { _, _ in [] },
|
||||||
|
// subscribe: { _, _, _, _, _ in
|
||||||
|
// subscribeCalled = true
|
||||||
|
// },
|
||||||
|
// unsubscribe: { _ in },
|
||||||
|
// sendEvent: { _, _ in },
|
||||||
|
// deriveIdentity: { _ in fatalError("should not derive identity") },
|
||||||
|
// now: { Date() }
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(subscribeCalled)
|
||||||
|
// XCTAssertEqual(manager.state, .noRelays)
|
||||||
|
// XCTAssertTrue(manager.initialLoadComplete)
|
||||||
|
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||||
|
// // Make sure we're getting an actual translated value and not the localization key
|
||||||
|
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func testSendWhenNoRelaysSurfacesError() {
|
||||||
|
// var sendCalled = false
|
||||||
|
// let deps = LocationNotesDependencies(
|
||||||
|
// relayLookup: { _, _ in [] },
|
||||||
|
// subscribe: { _, _, _, _, _ in },
|
||||||
|
// unsubscribe: { _ in },
|
||||||
|
// sendEvent: { _, _ in sendCalled = true },
|
||||||
|
// deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
|
// now: { Date() }
|
||||||
|
// )
|
||||||
|
//
|
||||||
|
// let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps)
|
||||||
|
// manager.send(content: "hello", nickname: "tester")
|
||||||
|
//
|
||||||
|
// XCTAssertFalse(sendCalled)
|
||||||
|
// XCTAssertEqual(manager.state, .noRelays)
|
||||||
|
// XCTAssertEqual(manager.errorMessage, String(localized: "location_notes.error.no_relays"))
|
||||||
|
// // Make sure we're getting an actual translated value and not the localization key
|
||||||
|
// XCTAssertNotEqual(manager.errorMessage, "location_notes.error.no_relays")
|
||||||
|
// }
|
||||||
|
|
||||||
|
func testSubscribeUsesGeoRelaysAndAppendsNotes() {
|
||||||
|
var relaysCaptured: [String] = []
|
||||||
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
|
var storedEOSE: (() -> Void)?
|
||||||
|
let deps = LocationNotesDependencies(
|
||||||
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
||||||
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
|
XCTAssertFalse(id.isEmpty)
|
||||||
|
relaysCaptured = relays
|
||||||
|
storedHandler = handler
|
||||||
|
storedEOSE = eose
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in },
|
||||||
|
sendEvent: { _, _ in },
|
||||||
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
||||||
|
now: { Date() }
|
||||||
|
)
|
||||||
|
|
||||||
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
||||||
|
XCTAssertEqual(relaysCaptured, ["wss://relay.one"])
|
||||||
|
XCTAssertEqual(manager.state, .loading)
|
||||||
|
|
||||||
|
var event = NostrEvent(
|
||||||
|
pubkey: "pub",
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [["g", "u4pruydq"]],
|
||||||
|
content: "hi"
|
||||||
|
)
|
||||||
|
event.id = "event1"
|
||||||
|
storedHandler?(event)
|
||||||
|
storedEOSE?()
|
||||||
|
|
||||||
|
XCTAssertEqual(manager.state, .ready)
|
||||||
|
XCTAssertEqual(manager.notes.count, 1)
|
||||||
|
XCTAssertEqual(manager.notes.first?.content, "hi")
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum TestError: Error {
|
||||||
|
case shouldNotDerive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationNotesCounterTests: XCTestCase {
|
||||||
|
func testSubscribeWithoutRelaysMarksUnavailable() {
|
||||||
|
var subscribeCalled = false
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in [] },
|
||||||
|
subscribe: { _, _, _, _, _ in subscribeCalled = true },
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
|
XCTAssertFalse(subscribeCalled)
|
||||||
|
XCTAssertFalse(counter.relayAvailable)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
XCTAssertEqual(counter.count, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSubscribeCountsUniqueNotes() {
|
||||||
|
var storedHandler: ((NostrEvent) -> Void)?
|
||||||
|
var storedEOSE: (() -> Void)?
|
||||||
|
let deps = LocationNotesCounterDependencies(
|
||||||
|
relayLookup: { _, _ in ["wss://relay.geo"] },
|
||||||
|
subscribe: { filter, id, relays, handler, eose in
|
||||||
|
XCTAssertEqual(relays, ["wss://relay.geo"])
|
||||||
|
XCTAssertEqual(filter.kinds, [1])
|
||||||
|
XCTAssertFalse(id.isEmpty)
|
||||||
|
storedHandler = handler
|
||||||
|
storedEOSE = eose
|
||||||
|
},
|
||||||
|
unsubscribe: { _ in }
|
||||||
|
)
|
||||||
|
|
||||||
|
let counter = LocationNotesCounter(testDependencies: deps)
|
||||||
|
counter.subscribe(geohash: "u4pruydq")
|
||||||
|
|
||||||
|
var first = NostrEvent(
|
||||||
|
pubkey: "pub",
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .textNote,
|
||||||
|
tags: [["g", "u4pruydq"]],
|
||||||
|
content: "a"
|
||||||
|
)
|
||||||
|
first.id = "eventA"
|
||||||
|
storedHandler?(first)
|
||||||
|
|
||||||
|
let duplicate = first
|
||||||
|
storedHandler?(duplicate)
|
||||||
|
|
||||||
|
storedEOSE?()
|
||||||
|
|
||||||
|
XCTAssertTrue(counter.relayAvailable)
|
||||||
|
XCTAssertEqual(counter.count, 1)
|
||||||
|
XCTAssertTrue(counter.initialLoadComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,6 @@ import CoreBluetooth
|
|||||||
/// simulated connections between peers. Tests call `simulateConnectedPeer` /
|
/// simulated connections between peers. Tests call `simulateConnectedPeer` /
|
||||||
/// `simulateDisconnectedPeer` to manage topology.
|
/// `simulateDisconnectedPeer` to manage topology.
|
||||||
/// - `resetTestBus()` clears global state and is called in test `setUp()`.
|
/// - `resetTestBus()` clears global state and is called in test `setUp()`.
|
||||||
/// - `_testRegister()` registers a node immediately on creation for deterministic routing.
|
|
||||||
/// - `messageDeliveryHandler` and `packetDeliveryHandler` let tests observe messages/packets
|
/// - `messageDeliveryHandler` and `packetDeliveryHandler` let tests observe messages/packets
|
||||||
/// as they flow, enabling scenarios like manual encryption/relay.
|
/// as they flow, enabling scenarios like manual encryption/relay.
|
||||||
/// - A thread-safe `seenMessageIDs` set prevents double-delivery races during flooding.
|
/// - A thread-safe `seenMessageIDs` set prevents double-delivery races during flooding.
|
||||||
@@ -33,7 +32,7 @@ final class MockBLEService: NSObject {
|
|||||||
// MARK: - Properties matching BLEService
|
// MARK: - Properties matching BLEService
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
var myPeerID: String = "MOCK1234"
|
var myPeerID: PeerID = "MOCK1234"
|
||||||
var myNickname: String = "MockUser"
|
var myNickname: String = "MockUser"
|
||||||
|
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
@@ -41,7 +40,7 @@ final class MockBLEService: NSObject {
|
|||||||
// Test-specific properties
|
// Test-specific properties
|
||||||
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
|
var sentMessages: [(message: BitchatMessage, packet: BitchatPacket)] = []
|
||||||
var sentPackets: [BitchatPacket] = []
|
var sentPackets: [BitchatPacket] = []
|
||||||
var connectedPeers: Set<String> = []
|
var connectedPeers: Set<PeerID> = []
|
||||||
var messageDeliveryHandler: ((BitchatMessage) -> Void)?
|
var messageDeliveryHandler: ((BitchatMessage) -> Void)?
|
||||||
var packetDeliveryHandler: ((BitchatPacket) -> Void)?
|
var packetDeliveryHandler: ((BitchatPacket) -> Void)?
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ final class MockBLEService: NSObject {
|
|||||||
return myNickname
|
return myNickname
|
||||||
}
|
}
|
||||||
|
|
||||||
var peerID: String {
|
var peerID: PeerID {
|
||||||
return myPeerID
|
return myPeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,8 +72,8 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - In-memory test bus (for E2E/Integration)
|
// MARK: - In-memory test bus (for E2E/Integration)
|
||||||
/// Global per-process bus for deterministic routing in tests.
|
/// Global per-process bus for deterministic routing in tests.
|
||||||
private static var registry: [String: MockBLEService] = [:]
|
private static var registry: [PeerID: MockBLEService] = [:]
|
||||||
private static var adjacency: [String: Set<String>] = [:]
|
private static var adjacency: [PeerID: Set<PeerID>] = [:]
|
||||||
|
|
||||||
/// Clears global bus state. Call from test `setUp()`.
|
/// Clears global bus state. Call from test `setUp()`.
|
||||||
static func resetTestBus() {
|
static func resetTestBus() {
|
||||||
@@ -95,7 +94,7 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adds an undirected edge between two peerIDs.
|
/// Adds an undirected edge between two peerIDs.
|
||||||
private static func connectPeers(_ a: String, _ b: String) {
|
private static func connectPeers(_ a: PeerID, _ b: PeerID) {
|
||||||
var setA = adjacency[a] ?? []
|
var setA = adjacency[a] ?? []
|
||||||
setA.insert(b)
|
setA.insert(b)
|
||||||
adjacency[a] = setA
|
adjacency[a] = setA
|
||||||
@@ -105,16 +104,11 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Removes an undirected edge between two peerIDs.
|
/// Removes an undirected edge between two peerIDs.
|
||||||
private static func disconnectPeers(_ a: String, _ b: String) {
|
private static func disconnectPeers(_ a: PeerID, _ b: PeerID) {
|
||||||
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
if var setA = adjacency[a] { setA.remove(b); adjacency[a] = setA }
|
||||||
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
if var setB = adjacency[b] { setB.remove(a); adjacency[b] = setB }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-only: register this instance on the bus immediately.
|
|
||||||
func _testRegister() {
|
|
||||||
registerIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
// Mock implementation - do nothing
|
// Mock implementation - do nothing
|
||||||
}
|
}
|
||||||
@@ -123,7 +117,7 @@ final class MockBLEService: NSObject {
|
|||||||
// Mock implementation - do nothing
|
// Mock implementation - do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
func isPeerConnected(_ peerID: String) -> Bool {
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||||
return connectedPeers.contains(peerID)
|
return connectedPeers.contains(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,15 +125,15 @@ final class MockBLEService: NSObject {
|
|||||||
"MockPeer_\(peerID)"
|
"MockPeer_\(peerID)"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPeerNicknames() -> [String: String] {
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
var nicknames: [String: String] = [:]
|
var nicknames: [PeerID: String] = [:]
|
||||||
for peer in connectedPeers {
|
for peer in connectedPeers {
|
||||||
nicknames[peer] = "MockPeer_\(peer)"
|
nicknames[peer] = "MockPeer_\(peer)"
|
||||||
}
|
}
|
||||||
return nicknames
|
return nicknames
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPeers() -> [String: String] {
|
func getPeers() -> [PeerID: String] {
|
||||||
return getPeerNicknames()
|
return getPeerNicknames()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +154,7 @@ final class MockBLEService: NSObject {
|
|||||||
if let payload = message.toBinaryPayload() {
|
if let payload = message.toBinaryPayload() {
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: 0x01,
|
type: 0x01,
|
||||||
senderID: myPeerID.data(using: .utf8)!,
|
senderID: myPeerID.id.data(using: .utf8)!,
|
||||||
recipientID: recipientID?.data(using: .utf8),
|
recipientID: recipientID?.data(using: .utf8),
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -188,7 +182,7 @@ final class MockBLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
let message = BitchatMessage(
|
let message = BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: myNickname,
|
sender: myNickname,
|
||||||
@@ -205,8 +199,8 @@ final class MockBLEService: NSObject {
|
|||||||
if let payload = message.toBinaryPayload() {
|
if let payload = message.toBinaryPayload() {
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: 0x01,
|
type: 0x01,
|
||||||
senderID: myPeerID.data(using: .utf8)!,
|
senderID: myPeerID.id.data(using: .utf8)!,
|
||||||
recipientID: recipientPeerID.data(using: .utf8)!,
|
recipientID: recipientPeerID.id.data(using: .utf8)!,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
signature: nil,
|
signature: nil,
|
||||||
@@ -283,7 +277,7 @@ final class MockBLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: - Test Helper Methods
|
// MARK: - Test Helper Methods
|
||||||
|
|
||||||
func simulateConnectedPeer(_ peerID: String) {
|
func simulateConnectedPeer(_ peerID: PeerID) {
|
||||||
registerIfNeeded()
|
registerIfNeeded()
|
||||||
MockBLEService.connectPeers(myPeerID, peerID)
|
MockBLEService.connectPeers(myPeerID, peerID)
|
||||||
connectedPeers.insert(peerID)
|
connectedPeers.insert(peerID)
|
||||||
@@ -291,7 +285,7 @@ final class MockBLEService: NSObject {
|
|||||||
delegate?.didUpdatePeerList(Array(connectedPeers))
|
delegate?.didUpdatePeerList(Array(connectedPeers))
|
||||||
}
|
}
|
||||||
|
|
||||||
func simulateDisconnectedPeer(_ peerID: String) {
|
func simulateDisconnectedPeer(_ peerID: PeerID) {
|
||||||
MockBLEService.disconnectPeers(myPeerID, peerID)
|
MockBLEService.disconnectPeers(myPeerID, peerID)
|
||||||
connectedPeers.remove(peerID)
|
connectedPeers.remove(peerID)
|
||||||
delegate?.didDisconnectFromPeer(peerID)
|
delegate?.didDisconnectFromPeer(peerID)
|
||||||
@@ -331,7 +325,7 @@ final class MockBLEService: NSObject {
|
|||||||
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
|
||||||
for neighbor in neighbors() {
|
for neighbor in neighbors() {
|
||||||
// Avoid immediate echo loopback to sender if known
|
// Avoid immediate echo loopback to sender if known
|
||||||
if let sender = message.senderPeer?.id, sender == neighbor.peerID { continue }
|
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
|
||||||
var relay = packet
|
var relay = packet
|
||||||
relay.ttl = nextTTL
|
relay.ttl = nextTTL
|
||||||
neighbor.simulateIncomingPacket(relay)
|
neighbor.simulateIncomingPacket(relay)
|
||||||
@@ -342,16 +336,31 @@ final class MockBLEService: NSObject {
|
|||||||
packetDeliveryHandler?(packet)
|
packetDeliveryHandler?(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getConnectedPeers() -> [String] {
|
func getConnectedPeers() -> [PeerID] {
|
||||||
return Array(connectedPeers)
|
return Array(connectedPeers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Compatibility methods for old tests
|
// MARK: - Compatibility methods for old tests
|
||||||
|
|
||||||
func sendPrivateMessage(_ content: String, to recipientPeerID: String, recipientNickname: String, messageID: String? = nil) {
|
func sendPrivateMessage(_ content: String, to recipientPeerID: PeerID, recipientNickname: String, messageID: String? = nil) {
|
||||||
sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString)
|
sendPrivateMessage(content, to: recipientPeerID, recipientNickname: recipientNickname, messageID: messageID ?? UUID().uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backward compatibility for older tests
|
// Backward compatibility for older tests
|
||||||
typealias MockSimplifiedBluetoothService = MockBLEService
|
typealias MockSimplifiedBluetoothService = MockBLEService
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
extension MockBLEService {
|
||||||
|
convenience init(peerID: PeerID, nickname: String) {
|
||||||
|
self.init()
|
||||||
|
myPeerID = peerID
|
||||||
|
mockNickname = nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func simulateConnection(with otherPeer: MockBLEService) {
|
||||||
|
simulateConnectedPeer(otherPeer.myPeerID)
|
||||||
|
otherPeer.simulateConnectedPeer(myPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user