Compare commits

...
Author SHA1 Message Date
islam 520ee63eac Update deprecated GitHub Actions 2026-04-14 20:34:30 +01:00
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
GitHub Action 3e137d784c Automated update of relay data - Sun Apr 12 06:32:24 UTC 2026 2026-04-12 06:32:24 +00:00
islam 452e9e74fd Run CI build on all PRs (not just to the main) 2026-04-10 22:31:49 +01:00
jack 3afd74ffc8 Ignore .claude directory 2026-04-05 19:04:18 -05:00
951e056a55 Extract message list into dedicated MessageListView (#1080)
* Extract message list into dedicated MessageListView

* Fix broken smoke test

Mounts two distinct fixtures to cover separate render branches: a truncatable message (>2000 chars, no payment tokens) and a payment message (lightning + cashu, private, partial delivery). Expectations guard that each fixture actually reaches its intended branch.

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-05 19:01:57 -05:00
GitHub Action d265cfc765 Automated update of relay data - Sun Apr 5 06:26:16 UTC 2026 2026-04-05 06:26:16 +00:00
IslamandGitHub 473f5c7cce String trimming in a centralized manner (#1079)
* String trimming in a centralized manner

* Fix empty-nickname case
2026-04-04 15:56:39 -05:00
b5834cfc76 Cache CI build artifacts (#1085)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 20:37:20 -05:00
IslamandGitHub 097d916da7 Refactor and Extract Media-related code (#1077)
* Extract media-related code from ContentView

* Refactor hard-coded prefix and directory names
2026-04-02 20:31:51 -05:00
IslamandGitHub 4b000b0785 Refactor & Extract image viewers (#1074)
* Extract `ImagePreviewView` + add `#Preview`

* Extract image pickers + dedupe and bg processing of images

* Include a dummy photo in the preview assets vs live url

* Fix development assets + url percent encoding

* Fix iOS vs macOS build issue

* Fix SPM-related issues
2026-04-02 18:49:17 -05:00
19437a0bc6 fix: pass environmentObject to people sheet to prevent PM reopen crash (#1069)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:37:08 -05:00
34bae4a89d Refactor Voice Messages (#1075)
* Extract voice-related code to a dedicated VM

* Minor optimization of permission request + fix ui bug

When isPreparing is being set to true before permission is granted, the UI is shown for a fraction of a second and it’s even worse if the permission is being asked constantly if the user drags the finger even when the alert is shown

* Remove dead code

* Remove unnecessary delegate conformance

* `actor VoiceRecorder` + other minor improvements

* Centralized opening system settings + open for mic access

* Better voice-recording state handling with Enum

* Remove manual timer management

* Simplify formatting + avoid jumping UI w/ countdown

* Add `requestingPermission` state to avoid repetitive calls

* Improve guarding of the states after awaits

* Fix potential “actor reentrancy across awaits” issue

* Remove short-circuiting on .idle + guard before error

* Fix playbackLabel’s formatting for duration vs remainder

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-04-02 11:26:13 -05:00
0331871980 fix: missing file broadcast and leave message signatures + allow local development (#1078)
* add signatures to file transfers and LEAVE messages

* chore: setup project for local development and signing

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-31 14:12:09 -05:00
islam f9f6ac92b8 Remove dead code 2026-03-30 10:12:30 +01:00
GitHub Action ca374fd823 Automated update of relay data - Sun Mar 29 06:24:11 UTC 2026 2026-03-29 06:24:11 +00:00
GitHub Action 96f2986d65 Automated update of relay data - Sun Mar 22 06:16:48 UTC 2026 2026-03-22 06:16:48 +00:00
GitHub Action b6e45e3228 Automated update of relay data - Sun Mar 15 06:20:36 UTC 2026 2026-03-15 06:20:36 +00:00
c88ab3dd05 Move GeoRelay fetch work off the main actor (#1060)
* Run GeoRelay fetch pipeline off main actor

* Capture GeoRelay session before detached fetch

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 20:24:47 -10:00
7d83310bc2 Expand Nostr and identity coverage (#1059)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 19:41:05 -10:00
264a95b61a Raise protocol coverage to 99 percent (#1058)
* Expand protocol coverage with edge-case tests

* Stabilize read receipt transport test

* Stabilize BLE duplicate packet test

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:55:41 -10:00
8562a76367 Raise Noise coverage to 99 percent (#1057)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 17:16:09 -10:00
7e86d2061f Expand coverage for transport, chat, and media flows (#1056)
* Expand coverage for transport, chat, and media flows

* Stabilize transport and media coverage tests

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 16:20:19 -10:00
a136b5b7e9 Expand coverage for relay, identity, and location flows (#1055)
* Expand coverage for relay, identity, and location flows

* Fix macOS SwiftPM CI failures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 12:50:46 -10:00
c043cf6354 Fix BLEService test local echo timing (#1054)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-03-12 09:53:58 -10:00
72093f9648 fix: disable macOS focus ring on chat input TextField (#905) (#1045)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:56:14 -10:00
187a5c3195 fix: allow verification sheet in private chat via sidebar navigation (#988) (#1046)
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-03-12 08:44:43 -10:00
GitHub Action 02b2a006c5 Automated update of relay data - Sun Mar 8 06:12:28 UTC 2026 2026-03-08 06:12:28 +00:00
GitHub Action 2403a6eb90 Automated update of relay data - Sun Mar 1 06:14:25 UTC 2026 2026-03-01 06:14:25 +00:00
GitHub Action e47c5ae7b3 Automated update of relay data - Sun Feb 22 06:15:08 UTC 2026 2026-02-22 06:15:08 +00:00
GitHub Action c1f5868d2d Automated update of relay data - Sun Feb 15 06:17:47 UTC 2026 2026-02-15 06:17:47 +00:00
GitHub Action 0515f4c6e8 Automated update of relay data - Sun Feb 8 06:17:26 UTC 2026 2026-02-08 06:17:26 +00:00
8c7e3e7b9b Harden Nostr validation and BLE announce tests (#1012)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2026-02-02 18:07:19 -10:00
90a5e8ba9d fix: Rate limit iOS peer notifications to prevent flood (#972)
* fix: Rate limit iOS peer notifications to prevent flood

- Remove aggressive formIntersection that cleared recentlySeenPeers
  when peers temporarily dropped, causing them to be treated as "new"
- Add 5-minute cooldown between notifications (aligns with Android)
- Use fixed notification identifier so iOS updates existing notification
  instead of creating new ones
- Only mark peers as seen when notification is sent, so peers arriving
  during cooldown are included in the next notification

Fixes notification spam every 10-30 seconds when peers fluctuate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: Bump version to 1.5.1

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:51:26 -10:00
GitHub Action 6206184862 Automated update of relay data - Sun Feb 1 06:16:49 UTC 2026 2026-02-01 06:16:49 +00:00
74cf0d89cc Fix iOS BLE mesh authentication issues in BLEService (#998)
* Fix iOS BLE mesh authentication bypass chain in BLEService

- Bind sender IDs to BLE connection UUIDs for peripherals and centrals to prevent spoofing
- Enforce explicit RSR request/response validation and remove legacy TTL==0 RSR path
- Remove TTL==0 unconditional acceptance for messages and file transfers
- Ensure gossip sync caching only occurs after a packet is accepted
- Preserve self‑sync TTL==0 dedup exception without weakening authentication

* fix: toctou in boundPeerID identified by codex

* fix: Remove unused variable and bump version to 1.5.1

- Remove unused messageType variable (compiler warning fix)
- Bump marketing version to 1.5.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:07:24 -10:00
138 changed files with 11116 additions and 2280 deletions
+22 -7
View File
@@ -5,23 +5,38 @@ on:
branches: branches:
- main - main
pull_request: pull_request:
branches:
- main
jobs: jobs:
test: test:
name: Run Swift Tests name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest runs-on: macos-latest
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
matrix:
include:
- name: app
path: .
- name: BitLogger
path: localPackages/BitLogger
- name: BitFoundation
path: localPackages/BitFoundation
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Set up Swift - name: Set up Swift
uses: swift-actions/setup-swift@v2 uses: swift-actions/setup-swift@v3
- name: Build the package - name: Cache build artifacts
run: swift build uses: actions/cache@v5
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests - name: Run Tests
run: swift test --parallel run: swift test --parallel --quiet --package-path ${{ matrix.path }}
+1
View File
@@ -8,6 +8,7 @@ plans/
## AI ## AI
CLAUDE.md CLAUDE.md
AGENTS.md AGENTS.md
.claude/
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
*.xcscmblueprint *.xcscmblueprint
+2 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.0 MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
@@ -9,3 +9,4 @@ DEVELOPMENT_TEAM = L3N5LHJD5Y
CODE_SIGN_STYLE = Automatic CODE_SIGN_STYLE = Automatic
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat
APP_GROUP_ID = group.chat.bitchat
+7 -1
View File
@@ -17,6 +17,7 @@ let package = Package(
], ],
dependencies:[ dependencies:[
.package(path: "localPackages/Arti"), .package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"), .package(path: "localPackages/BitLogger"),
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1") .package(url: "https://github.com/21-DOT-DEV/swift-secp256k1", exact: "0.21.1")
], ],
@@ -25,6 +26,7 @@ let package = Package(
name: "bitchat", name: "bitchat",
dependencies: [ dependencies: [
.product(name: "P256K", package: "swift-secp256k1"), .product(name: "P256K", package: "swift-secp256k1"),
.product(name: "BitFoundation", package: "BitFoundation"),
.product(name: "BitLogger", package: "BitLogger"), .product(name: "BitLogger", package: "BitLogger"),
.product(name: "Tor", package: "Arti") .product(name: "Tor", package: "Arti")
], ],
@@ -32,6 +34,7 @@ let package = Package(
exclude: [ exclude: [
"Info.plist", "Info.plist",
"Assets.xcassets", "Assets.xcassets",
"_PreviewHelpers/PreviewAssets.xcassets",
"bitchat.entitlements", "bitchat.entitlements",
"bitchat-macOS.entitlements", "bitchat-macOS.entitlements",
"LaunchScreen.storyboard", "LaunchScreen.storyboard",
@@ -43,7 +46,10 @@ let package = Package(
), ),
.testTarget( .testTarget(
name: "bitchatTests", name: "bitchatTests",
dependencies: ["bitchat"], dependencies: [
"bitchat",
.product(name: "BitFoundation", package: "BitFoundation")
],
path: "bitchatTests", path: "bitchatTests",
exclude: [ exclude: [
"Info.plist", "Info.plist",
+26 -4
View File
@@ -10,6 +10,8 @@
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; };
885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; }; 885BBED78092484A5B069461 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 4EB6BA1B8464F1EA38F4E286 /* P256K */; };
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9472F80953E001CF9B9 /* BitFoundation */; };
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = A6BCF9492F809550001CF9B9 /* BitFoundation */; };
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; }; A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E56F2E77036A0032EA8A /* BitLogger */; };
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; }; A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3E5712E7703760032EA8A /* BitLogger */; };
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; }; A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */ = {isa = PBXBuildFile; productRef = A6E3EA7E2E7706720032EA8A /* Tor */; };
@@ -156,6 +158,7 @@
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */, A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
3EE336D150427F736F32B56C /* P256K in Frameworks */, 3EE336D150427F736F32B56C /* P256K in Frameworks */,
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */, A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
B5A5CC493FFB3D8966548140 /* Frameworks */ = { B5A5CC493FFB3D8966548140 /* Frameworks */ = {
@@ -164,6 +167,7 @@
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */, A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
885BBED78092484A5B069461 /* P256K in Frameworks */, 885BBED78092484A5B069461 /* P256K in Frameworks */,
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */, A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
); );
}; };
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
@@ -223,6 +227,7 @@
B1D9136AA0083366353BFA2F /* P256K */, B1D9136AA0083366353BFA2F /* P256K */,
A6E3E5712E7703760032EA8A /* BitLogger */, A6E3E5712E7703760032EA8A /* BitLogger */,
A6E3EA802E7706A80032EA8A /* Tor */, A6E3EA802E7706A80032EA8A /* Tor */,
A6BCF9492F809550001CF9B9 /* BitFoundation */,
); );
productName = bitchat_macOS; productName = bitchat_macOS;
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */; productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
@@ -303,6 +308,7 @@
4EB6BA1B8464F1EA38F4E286 /* P256K */, 4EB6BA1B8464F1EA38F4E286 /* P256K */,
A6E3E56F2E77036A0032EA8A /* BitLogger */, A6E3E56F2E77036A0032EA8A /* BitLogger */,
A6E3EA7E2E7706720032EA8A /* Tor */, A6E3EA7E2E7706720032EA8A /* Tor */,
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
); );
productName = bitchat_iOS; productName = bitchat_iOS;
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */; productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
@@ -344,6 +350,7 @@
B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */,
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */, A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */,
A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */, A6E3EA7D2E7706720032EA8A /* XCLocalSwiftPackageReference "localPackages/Arti" */,
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */,
); );
preferredProjectObjectVersion = 90; preferredProjectObjectVersion = 90;
projectDirPath = ""; projectDirPath = "";
@@ -548,6 +555,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO; ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -558,7 +566,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -608,6 +616,7 @@
CODE_SIGNING_REQUIRED = YES; CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements; CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)"; CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)"; DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist; INFOPLIST_FILE = bitchat/Info.plist;
@@ -618,7 +627,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -654,7 +663,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -746,7 +755,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)"; MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -907,6 +916,10 @@
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */ /* Begin XCLocalSwiftPackageReference section */
A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitFoundation;
};
A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = { A6E3E56E2E77036A0032EA8A /* XCLocalSwiftPackageReference "localPackages/BitLogger" */ = {
isa = XCLocalSwiftPackageReference; isa = XCLocalSwiftPackageReference;
relativePath = localPackages/BitLogger; relativePath = localPackages/BitLogger;
@@ -934,6 +947,15 @@
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
productName = P256K; productName = P256K;
}; };
A6BCF9472F80953E001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
productName = BitFoundation;
};
A6BCF9492F809550001CF9B9 /* BitFoundation */ = {
isa = XCSwiftPackageProductDependency;
package = A6BCF9462F80953E001CF9B9 /* XCLocalSwiftPackageReference "localPackages/BitFoundation" */;
productName = BitFoundation;
};
A6E3E56F2E77036A0032EA8A /* BitLogger */ = { A6E3E56F2E77036A0032EA8A /* BitLogger */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = BitLogger; productName = BitLogger;
+1
View File
@@ -8,6 +8,7 @@
import Tor import Tor
import SwiftUI import SwiftUI
import BitFoundation
import UserNotifications import UserNotifications
@main @main
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0 @Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0 @Published private(set) var progress: Double = 0
/// rounded so 4.9s shows "00:05"
var roundedDuration: Int {
guard duration.isFinite else { return 0 }
return Int(duration.rounded())
}
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
var remainingSeconds: Int {
let remaining = max(0, duration - currentTime)
return min(roundedDuration, Int(ceil(remaining)))
}
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
private var timer: Timer? private var timer: Timer?
private var url: URL private var url: URL
+75 -99
View File
@@ -2,8 +2,7 @@ import Foundation
import AVFoundation import AVFoundation
/// Manages audio capture for mesh voice notes with predictable encoding settings. /// Manages audio capture for mesh voice notes with predictable encoding settings.
/// Recording runs on an internal serial queue to avoid AVAudioSession contention. actor VoiceRecorder {
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
enum RecorderError: Error { enum RecorderError: Error {
case microphoneAccessDenied case microphoneAccessDenied
case recorderInitializationFailed case recorderInitializationFailed
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
static let shared = VoiceRecorder() static let shared = VoiceRecorder()
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
private let paddingInterval: TimeInterval = 0.5 private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120 private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1
private var recorder: AVAudioRecorder? private var recorder: AVAudioRecorder?
private var currentURL: URL? private var currentURL: URL?
private var stopWorkItem: DispatchWorkItem?
private override init() {
super.init()
}
// MARK: - Permissions // MARK: - Permissions
@discardableResult nonisolated
func requestPermission() async -> Bool { func requestPermission() async -> Bool {
#if os(iOS) #if os(iOS)
return await withCheckedContinuation { continuation in return await withCheckedContinuation { continuation in
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
// MARK: - Recording Lifecycle // MARK: - Recording Lifecycle
@discardableResult
func startRecording() throws -> URL { func startRecording() throws -> URL {
try queue.sync { if recorder?.isRecording == true {
if recorder?.isRecording == true { throw RecorderError.recordingInProgress
throw RecorderError.recordingInProgress
}
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.delegate = self
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
stopWorkItem?.cancel()
stopWorkItem = nil
return outputURL
} }
#if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied
}
#if targetEnvironment(simulator)
// allowBluetoothHFP is not available on iOS Simulator
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
)
#endif
try session.setActive(true, options: .notifyOthersOnDeactivation)
#endif
#if os(macOS)
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
}
#endif
let outputURL = try makeOutputURL()
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
audioRecorder.isMeteringEnabled = true
audioRecorder.prepareToRecord()
audioRecorder.record(forDuration: maxRecordingDuration)
recorder = audioRecorder
currentURL = outputURL
return outputURL
} }
func stopRecording(completion: @escaping (URL?) -> Void) { func stopRecording() async -> URL? {
queue.async { [weak self] in guard let recorder, recorder.isRecording else {
guard let self = self, let recorder = self.recorder, recorder.isRecording else { return currentURL
completion(self?.currentURL)
return
}
let item = DispatchWorkItem { [weak self] in
guard let self = self else { return }
recorder.stop()
self.cleanupSession()
let url = self.currentURL
self.recorder = nil
self.currentURL = url
completion(url)
}
self.stopWorkItem = item
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
} }
let sessionURL = currentURL
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
recorder.stop()
// A new session may have started during the sleep don't touch its state
if self.recorder === recorder {
cleanupSession()
self.recorder = nil
currentURL = nil
}
return sessionURL
} }
func cancelRecording() { func cancelRecording() {
queue.async { [weak self] in if let recorder, recorder.isRecording {
guard let self = self else { return } recorder.stop()
self.stopWorkItem?.cancel()
self.stopWorkItem = nil
if let recorder = self.recorder, recorder.isRecording {
recorder.stop()
}
self.cleanupSession()
if let url = self.currentURL {
try? FileManager.default.removeItem(at: url)
}
self.recorder = nil
self.currentURL = nil
} }
} cleanupSession()
if let currentURL {
// MARK: - Metering try? FileManager.default.removeItem(at: currentURL)
func currentAveragePower() -> Float {
queue.sync {
recorder?.updateMeters()
return recorder?.averagePower(forChannel: 0) ?? -160
} }
recorder = nil
currentURL = nil
} }
// MARK: - Helpers // MARK: - Helpers
+1
View File
@@ -81,6 +81,7 @@
/// ///
import Foundation import Foundation
import BitFoundation
// MARK: - Three-Layer Identity Model // MARK: - Three-Layer Identity Model
@@ -91,6 +91,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
@@ -331,16 +332,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
// Update nickname index // Update nickname index
if let existingIdentity = self.cache.socialIdentities[identity.fingerprint] { if let previousClaimedNickname,
// Remove old nickname from index if changed previousClaimedNickname != identity.claimedNickname {
if existingIdentity.claimedNickname != identity.claimedNickname { self.cache.nicknameIndex[previousClaimedNickname]?.remove(identity.fingerprint)
self.cache.nicknameIndex[existingIdentity.claimedNickname]?.remove(identity.fingerprint) if self.cache.nicknameIndex[previousClaimedNickname]?.isEmpty == true {
if self.cache.nicknameIndex[existingIdentity.claimedNickname]?.isEmpty == true { self.cache.nicknameIndex.removeValue(forKey: previousClaimedNickname)
self.cache.nicknameIndex.removeValue(forKey: existingIdentity.claimedNickname)
}
} }
} }
@@ -532,4 +532,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
return cache.verifiedFingerprints return cache.verifiedFingerprints
} }
} }
var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex }
}
func debugEphemeralSession(for peerID: PeerID) -> EphemeralIdentity? {
queue.sync { ephemeralSessions[peerID] }
}
func debugLastInteraction(for fingerprint: String) -> Date? {
queue.sync { cache.lastInteractions[fingerprint] }
}
} }
+6 -4
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -37,12 +39,12 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+68
View File
@@ -0,0 +1,68 @@
//
// BitchatMessage+Media.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension BitchatMessage {
enum Media {
case voice(URL)
case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
}
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
private struct Cache {
let filesDir: URL?
static let shared = Cache()
private init() {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let filesDir = base.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
self.filesDir = filesDir
} catch {
filesDir = nil
}
}
}
func mediaAttachment(for nickname: String) -> Media? {
guard let baseDirectory = Cache.shared.filesDir else { return nil }
func url(for category: MimeType.Category) -> URL? {
guard content.hasPrefix(category.messagePrefix),
let filename = String(content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty
else {
return nil
}
// Check outgoing first for sent messages, incoming for received
let subdir = sender == nickname ? "\(category.mediaDir)/outgoing" : "\(category.mediaDir)/incoming"
// Construct URL directly without fileExists check (avoids blocking disk I/O in view body)
// Files are checked during playback/display, so missing files fail gracefully
let directory = baseDirectory.appendingPathComponent(subdir, isDirectory: true)
return directory.appendingPathComponent(filename)
}
if let url = url(for: .audio) {
return .voice(url)
}
if let url = url(for: .image) {
return .image(url)
}
return nil
}
}
+2 -1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
/// Represents a user-visible message in the BitChat system. /// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages, /// Handles both broadcast messages and private encrypted messages,
@@ -338,7 +339,7 @@ extension BitchatMessage {
extension Array where Element == BitchatMessage { extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest) /// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] { func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false } let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else { guard arr.count > 1 else {
return arr return arr
} }
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
/// The core packet structure for all BitChat protocol messages. /// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network, /// Encapsulates all data needed for routing through the mesh network,
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
/// Represents a peer in the BitChat network with all associated metadata /// Represents a peer in the BitChat network with all associated metadata
struct BitchatPeer: Equatable { struct BitchatPeer: Equatable {
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
struct ReadReceipt: Codable { struct ReadReceipt: Codable {
let originalMessageID: String let originalMessageID: String
+26 -1
View File
@@ -384,6 +384,16 @@ final class NoiseCipherState {
replayWindow[i] = 0 replayWindow[i] = 0
} }
} }
#if DEBUG
func setNonceForTesting(_ nonce: UInt64) {
self.nonce = nonce
}
func extractNonceFromCiphertextPayloadForTesting(_ combinedPayload: Data) throws -> (nonce: UInt64, ciphertext: Data)? {
try extractNonceFromCiphertextPayload(combinedPayload)
}
#endif
} }
// MARK: - Symmetric State // MARK: - Symmetric State
@@ -585,8 +595,9 @@ final class NoiseHandshakeState {
break // No pre-message keys break // No pre-message keys
case .IK, .NK: case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic { if role == .initiator, let remoteStatic = remoteStaticPublic {
_ = symmetricState.getHandshakeHash()
symmetricState.mixHash(remoteStatic.rawRepresentation) symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
symmetricState.mixHash(localStatic.rawRepresentation)
} }
} }
} }
@@ -861,6 +872,20 @@ final class NoiseHandshakeState {
func getHandshakeHash() -> Data { func getHandshakeHash() -> Data {
return symmetricState.getHandshakeHash() return symmetricState.getHandshakeHash()
} }
#if DEBUG
func performDHOperationForTesting(_ pattern: NoiseMessagePattern) throws {
try performDHOperation(pattern)
}
func setCurrentPatternForTesting(_ currentPattern: Int) {
self.currentPattern = currentPattern
}
func setRemoteEphemeralPublicKeyForTesting(_ key: Curve25519.KeyAgreement.PublicKey?) {
self.remoteEphemeralPublic = key
}
#endif
} }
// MARK: - Pattern Extensions // MARK: - Pattern Extensions
+1
View File
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
final class NoiseRateLimiter { final class NoiseRateLimiter {
+1
View File
@@ -9,6 +9,7 @@
import BitLogger import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
class NoiseSession { class NoiseSession {
let peerID: PeerID let peerID: PeerID
+24 -12
View File
@@ -9,11 +9,13 @@
import BitLogger import BitLogger
import CryptoKit import CryptoKit
import Foundation import Foundation
import BitFoundation
final class NoiseSessionManager { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent) private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
// Callbacks // Callbacks
@@ -23,8 +25,28 @@ final class NoiseSessionManager {
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) { init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
self.localStaticKey = localStaticKey self.localStaticKey = localStaticKey
self.keychain = keychain self.keychain = keychain
self.sessionFactory = { peerID, role in
SecureNoiseSession(
peerID: peerID,
role: role,
keychain: keychain,
localStaticKey: localStaticKey
)
}
} }
#if DEBUG
init(
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
keychain: KeychainManagerProtocol,
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
) {
self.localStaticKey = localStaticKey
self.keychain = keychain
self.sessionFactory = sessionFactory
}
#endif
// MARK: - Session Management // MARK: - Session Management
func getSession(for peerID: PeerID) -> NoiseSession? { func getSession(for peerID: PeerID) -> NoiseSession? {
@@ -66,12 +88,7 @@ final class NoiseSessionManager {
} }
// Create new initiator session // Create new initiator session
let session = SecureNoiseSession( let session = sessionFactory(peerID, .initiator)
peerID: peerID,
role: .initiator,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = session sessions[peerID] = session
do { do {
@@ -117,12 +134,7 @@ final class NoiseSessionManager {
// Get or create session // Get or create session
let session: NoiseSession let session: NoiseSession
if shouldCreateNew { if shouldCreateNew {
let newSession = SecureNoiseSession( let newSession = sessionFactory(peerID, .responder)
peerID: peerID,
role: .responder,
keychain: keychain,
localStaticKey: localStaticKey
)
sessions[peerID] = newSession sessions[peerID] = newSession
session = newSession session = newSession
} else { } else {
+5 -1
View File
@@ -10,7 +10,7 @@ import Foundation
final class SecureNoiseSession: NoiseSession { final class SecureNoiseSession: NoiseSession {
private(set) var messageCount: UInt64 = 0 private(set) var messageCount: UInt64 = 0
private let sessionStartTime = Date() private var sessionStartTime = Date()
private(set) var lastActivityTime = Date() private(set) var lastActivityTime = Date()
override func encrypt(_ plaintext: Data) throws -> Data { override func encrypt(_ plaintext: Data) throws -> Data {
@@ -77,5 +77,9 @@ final class SecureNoiseSession: NoiseSession {
func setMessageCountForTesting(_ count: UInt64) { func setMessageCountForTesting(_ count: UInt64) {
messageCount = count messageCount = count
} }
func setSessionStartTimeForTesting(_ date: Date) {
sessionStartTime = date
}
#endif #endif
} }
+222 -112
View File
@@ -8,39 +8,154 @@ import AppKit
#endif #endif
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. /// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
var notificationCenter: NotificationCenter
var now: () -> Date
var remoteURL: URL
var fetchInterval: TimeInterval
var refreshCheckInterval: TimeInterval
var retryInitialSeconds: TimeInterval
var retryMaxSeconds: TimeInterval
var awaitTorReady: @Sendable () async -> Bool
var makeFetchData: @MainActor @Sendable () -> (@Sendable (URLRequest) async throws -> Data)
var readData: (URL) -> Data?
var writeData: (Data, URL) throws -> Void
var cacheURL: () -> URL?
var bundledCSVURLs: () -> [URL]
var currentDirectoryPath: () -> String?
var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name?
var autoStart: Bool
}
private extension GeoRelayDirectoryDependencies {
@MainActor
static func live() -> Self {
#if os(iOS)
let activeNotificationName: Notification.Name? = UIApplication.didBecomeActiveNotification
#elseif os(macOS)
let activeNotificationName: Notification.Name? = NSApplication.didBecomeActiveNotification
#else
let activeNotificationName: Notification.Name? = nil
#endif
return Self(
userDefaults: .standard,
notificationCenter: .default,
now: Date.init,
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
retryMaxSeconds: TransportConfig.geoRelayRetryMaxSeconds,
awaitTorReady: { await TorManager.shared.awaitReady() },
makeFetchData: {
let session = TorURLSession.shared.session
return { request in
let (data, _) = try await session.data(for: request)
return data
}
},
readData: { try? Data(contentsOf: $0) },
writeData: { data, url in
try data.write(to: url, options: .atomic)
},
cacheURL: {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("georelays_cache.csv")
} catch {
return nil
}
},
bundledCSVURLs: {
[
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
},
currentDirectoryPath: { FileManager.default.currentDirectoryPath },
retrySleep: { delay in
let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds)
},
activeNotificationName: activeNotificationName,
autoStart: true
)
}
}
@MainActor @MainActor
final class GeoRelayDirectory { final class GeoRelayDirectory {
struct Entry: Hashable { private final class CleanupState {
let notificationCenter: NotificationCenter
var observers: [NSObjectProtocol] = []
var refreshTimer: Timer?
var retryTask: Task<Void, Never>?
init(notificationCenter: NotificationCenter) {
self.notificationCenter = notificationCenter
}
deinit {
observers.forEach { notificationCenter.removeObserver($0) }
refreshTimer?.invalidate()
retryTask?.cancel()
}
}
struct Entry: Hashable, Sendable {
let host: String let host: String
let lat: Double let lat: Double
let lon: Double let lon: Double
} }
private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: String)
case torNotReady
case invalidData
case network(String)
}
static let shared = GeoRelayDirectory() static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = [] private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt" private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")! private let dependencies: GeoRelayDirectoryDependencies
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds private let cleanupState: CleanupState
private var refreshTimer: Timer?
private var retryTask: Task<Void, Never>?
private var retryAttempt: Int = 0 private var retryAttempt: Int = 0
private var isFetching: Bool = false private var isFetching: Bool = false
private var observers: [NSObjectProtocol] = []
private init() { private init() {
self.dependencies = .live()
self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
entries = loadLocalEntries() entries = loadLocalEntries()
registerObservers() if dependencies.autoStart {
startRefreshTimer() registerObservers()
prefetchIfNeeded() startRefreshTimer()
prefetchIfNeeded()
}
} }
deinit { internal init(dependencies: GeoRelayDirectoryDependencies) {
observers.forEach { NotificationCenter.default.removeObserver($0) } self.dependencies = dependencies
refreshTimer?.invalidate() self.cleanupState = CleanupState(notificationCenter: dependencies.notificationCenter)
retryTask?.cancel() entries = loadLocalEntries()
if dependencies.autoStart {
registerObservers()
startRefreshTimer()
prefetchIfNeeded()
}
} }
/// Returns up to `count` relay URLs (wss://) closest to the geohash center. /// Returns up to `count` relay URLs (wss://) closest to the geohash center.
@@ -83,13 +198,13 @@ final class GeoRelayDirectory {
func prefetchIfNeeded(force: Bool = false) { func prefetchIfNeeded(force: Bool = false) {
guard !isFetching else { return } guard !isFetching else { return }
let now = Date() let now = dependencies.now()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast let last = dependencies.userDefaults.object(forKey: lastFetchKey) as? Date ?? .distantPast
if !force { if !force {
guard now.timeIntervalSince(last) >= fetchInterval else { return } guard now.timeIntervalSince(last) >= dependencies.fetchInterval else { return }
} else if last != .distantPast, } else if last != .distantPast,
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds { now.timeIntervalSince(last) < dependencies.retryInitialSeconds {
// Skip forced fetches if we just refreshed moments ago. // Skip forced fetches if we just refreshed moments ago.
return return
} }
@@ -103,51 +218,73 @@ final class GeoRelayDirectory {
isFetching = true isFetching = true
let request = URLRequest( let request = URLRequest(
url: remoteURL, url: dependencies.remoteURL,
cachePolicy: .reloadIgnoringLocalCacheData, cachePolicy: .reloadIgnoringLocalCacheData,
timeoutInterval: 15 timeoutInterval: 15
) )
let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData()
Task.detached { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
let ready = await TorManager.shared.awaitReady() let outcome = await Self.fetchRemoteOutcome(
if !ready { request: request,
await self.handleFetchFailure(.torNotReady) awaitTorReady: awaitTorReady,
return fetchData: fetchData
} )
do { switch outcome {
let (data, _) = try await TorURLSession.shared.session.data(for: request) case .success(let parsed, let csv):
guard let text = String(data: data, encoding: .utf8) else { self.handleFetchSuccess(entries: parsed, csv: csv)
await self.handleFetchFailure(.invalidData) case .torNotReady:
return self.handleFetchFailure(.torNotReady)
} case .invalidData:
self.handleFetchFailure(.invalidData)
let parsed = GeoRelayDirectory.parseCSV(text) case .network(let description):
guard !parsed.isEmpty else { self.handleFetchFailure(.network(description))
await self.handleFetchFailure(.invalidData)
return
}
await self.handleFetchSuccess(entries: parsed, csv: text)
} catch {
await self.handleFetchFailure(.network(error))
} }
} }
} }
nonisolated private static func fetchRemoteOutcome(
request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) {
let ready = await awaitTorReady()
guard ready else { return .torNotReady }
do {
let data = try await fetchData(request)
guard let text = String(data: data, encoding: .utf8) else {
return .invalidData
}
let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch {
return .network(error.localizedDescription)
}
}.value
}
private enum FetchFailure { private enum FetchFailure {
case torNotReady case torNotReady
case invalidData case invalidData
case network(Error) case network(String)
} }
@MainActor @MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: String) { private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed entries = parsed
persistCache(csv) persistCache(csv)
UserDefaults.standard.set(Date(), forKey: lastFetchKey) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session) SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
isFetching = false isFetching = false
retryAttempt = 0 retryAttempt = 0
@@ -161,8 +298,8 @@ final class GeoRelayDirectory {
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session) SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
case .invalidData: case .invalidData:
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session) SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
case .network(let error): case .network(let errorDescription):
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session) SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(errorDescription)", category: .session)
} }
isFetching = false isFetching = false
scheduleRetry() scheduleRetry()
@@ -171,32 +308,34 @@ final class GeoRelayDirectory {
@MainActor @MainActor
private func scheduleRetry() { private func scheduleRetry() {
retryAttempt = min(retryAttempt + 1, 10) retryAttempt = min(retryAttempt + 1, 10)
let base = TransportConfig.geoRelayRetryInitialSeconds let base = dependencies.retryInitialSeconds
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds let maxDelay = dependencies.retryMaxSeconds
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0))) let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
let calculated = base * multiplier let calculated = base * multiplier
let delay = min(maxDelay, max(base, calculated)) let delay = min(maxDelay, max(base, calculated))
cancelRetry() cancelRetry()
retryTask = Task { [weak self] in cleanupState.retryTask = Task { [weak self] in
let nanoseconds = UInt64(delay * 1_000_000_000) guard let self else { return }
try? await Task.sleep(nanoseconds: nanoseconds) await self.dependencies.retrySleep(delay)
guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
self?.prefetchIfNeeded(force: true) self.prefetchIfNeeded(force: true)
} }
} }
} }
@MainActor @MainActor
private func cancelRetry() { private func cancelRetry() {
retryTask?.cancel() cleanupState.retryTask?.cancel()
retryTask = nil cleanupState.retryTask = nil
} }
private func persistCache(_ text: String) { private func persistCache(_ text: String) {
guard let url = cacheURL() else { return } guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try text.data(using: .utf8)?.write(to: url, options: .atomic) try dependencies.writeData(data, url)
} catch { } catch {
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session) SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
} }
@@ -205,22 +344,18 @@ final class GeoRelayDirectory {
// MARK: - Loading // MARK: - Loading
private func loadLocalEntries() -> [Entry] { private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present // Prefer cached file if present
if let cache = cacheURL(), if let cache = dependencies.cacheURL(),
let data = try? Data(contentsOf: cache), let data = dependencies.readData(cache),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
} }
// Try bundled resource(s) // Try bundled resource(s)
let bundleCandidates = [ let bundleCandidates = dependencies.bundledCSVURLs()
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates { for url in bundleCandidates {
if let data = try? Data(contentsOf: url), if let data = dependencies.readData(url),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text) let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr } if !arr.isEmpty { return arr }
@@ -228,8 +363,8 @@ final class GeoRelayDirectory {
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = FileManager.default.currentDirectoryPath as String?, if let cwd = dependencies.currentDirectoryPath(),
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) { let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text) return Self.parseCSV(text)
} }
@@ -242,10 +377,9 @@ final class GeoRelayDirectory {
var result: Set<Entry> = [] var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
for (idx, raw) in lines.enumerated() { for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard let line = raw.trimmedOrNilIfEmpty else { continue }
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue } if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } let parts = line.split(separator: ",").map { $0.trimmed }
guard parts.count >= 3 else { continue } guard parts.count >= 3 else { continue }
var host = parts[0] var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "") host = host.replacingOccurrences(of: "https://", with: "")
@@ -259,25 +393,9 @@ final class GeoRelayDirectory {
return Array(result) return Array(result)
} }
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch {
return nil
}
}
// MARK: - Observers & Timers // MARK: - Observers & Timers
private func registerObservers() { private func registerObservers() {
let center = NotificationCenter.default let center = dependencies.notificationCenter
let torReady = center.addObserver( let torReady = center.addObserver(
forName: .TorDidBecomeReady, forName: .TorDidBecomeReady,
@@ -289,38 +407,26 @@ final class GeoRelayDirectory {
self.prefetchIfNeeded(force: true) self.prefetchIfNeeded(force: true)
} }
} }
observers.append(torReady) cleanupState.observers.append(torReady)
#if os(iOS) if let activeNotificationName = dependencies.activeNotificationName {
let didBecomeActive = center.addObserver( let didBecomeActive = center.addObserver(
forName: UIApplication.didBecomeActiveNotification, forName: activeNotificationName,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
guard let self else { return } guard let self else { return }
Task { @MainActor in Task { @MainActor in
self.prefetchIfNeeded() self.prefetchIfNeeded()
}
} }
cleanupState.observers.append(didBecomeActive)
} }
observers.append(didBecomeActive)
#elseif os(macOS)
let didBecomeActive = center.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
guard let self else { return }
Task { @MainActor in
self.prefetchIfNeeded()
}
}
observers.append(didBecomeActive)
#endif
} }
private func startRefreshTimer() { private func startRefreshTimer() {
refreshTimer?.invalidate() cleanupState.refreshTimer?.invalidate()
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds let interval = dependencies.refreshCheckInterval
guard interval > 0 else { return } guard interval > 0 else { return }
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
@@ -329,9 +435,13 @@ final class GeoRelayDirectory {
self.prefetchIfNeeded() self.prefetchIfNeeded()
} }
} }
refreshTimer = timer cleanupState.refreshTimer = timer
RunLoop.main.add(timer, forMode: .common) RunLoop.main.add(timer, forMode: .common)
} }
var debugRetryAttempt: Int { retryAttempt }
var debugHasRetryTask: Bool { cleanupState.retryTask != nil }
var debugObserverCount: Int { cleanupState.observers.count }
} }
// MARK: - Distance // MARK: - Distance
+1
View File
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitFoundation
// MARK: - BitChat-over-Nostr Adapter // MARK: - BitChat-over-Nostr Adapter
+22 -2
View File
@@ -109,7 +109,7 @@ struct NostrProtocol {
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if teleported { if teleported {
@@ -152,7 +152,7 @@ struct NostrProtocol {
nickname: String? = nil nickname: String? = nil
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
let event = NostrEvent( let event = NostrEvent(
@@ -529,6 +529,26 @@ struct NostrEvent: Codable {
return signed return signed
} }
/// Validate that the event ID and Schnorr signature match the content and pubkey.
/// Returns false when the signature is missing, malformed, or does not verify.
func isValidSignature() -> Bool {
guard let sig = sig,
let sigData = Data(hexString: sig),
let pubData = Data(hexString: pubkey),
sigData.count == 64,
pubData.count == 32,
let signature = try? P256K.Schnorr.SchnorrSignature(dataRepresentation: sigData),
let (expectedId, eventHash) = try? calculateEventId(),
expectedId == id
else {
return false
}
var messageBytes = [UInt8](eventHash)
let xonly = P256K.Schnorr.XonlyKey(dataRepresentation: pubData)
return xonly.isValid(signature, for: &messageBytes)
}
private func calculateEventId() throws -> (String, Data) { private func calculateEventId() throws -> (String, Data) {
let serialized = [ let serialized = [
0, 0,
+198 -68
View File
@@ -4,6 +4,100 @@ import Network
import Combine import Combine
import Tor import Tor
protocol NostrRelayConnectionProtocol: AnyObject {
func resume()
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?)
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void)
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void)
}
protocol NostrRelaySessionProtocol {
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol
}
private final class URLSessionWebSocketTaskAdapter: NostrRelayConnectionProtocol {
private let base: URLSessionWebSocketTask
init(base: URLSessionWebSocketTask) {
self.base = base
}
func resume() {
base.resume()
}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
base.cancel(with: closeCode, reason: reason)
}
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
base.send(message, completionHandler: completionHandler)
}
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
base.receive(completionHandler: completionHandler)
}
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
base.sendPing(pongReceiveHandler: pongReceiveHandler)
}
}
private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
}
}
struct NostrRelayManagerDependencies {
var activationAllowed: () -> Bool
var userTorEnabled: () -> Bool
var hasMutualFavorites: () -> Bool
var hasLocationPermission: () -> Bool
var mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
var locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
var torEnforced: () -> Bool
var torIsReady: () -> Bool
var torIsForeground: () -> Bool
var awaitTorReady: (@escaping (Bool) -> Void) -> Void
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
}
private extension NostrRelayManagerDependencies {
@MainActor
static func live() -> Self {
Self(
activationAllowed: { NetworkActivationService.shared.activationAllowed },
userTorEnabled: { NetworkActivationService.shared.userTorEnabled },
hasMutualFavorites: { !FavoritesPersistenceService.shared.mutualFavorites.isEmpty },
hasLocationPermission: { LocationChannelManager.shared.permissionState == .authorized },
mutualFavoritesPublisher: FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher(),
locationPermissionPublisher: LocationChannelManager.shared.$permissionState.eraseToAnyPublisher(),
torEnforced: { TorManager.shared.torEnforced },
torIsReady: { TorManager.shared.isReady },
torIsForeground: { TorManager.shared.isForeground() },
awaitTorReady: { completion in
Task.detached {
let ready = await TorManager.shared.awaitReady()
await MainActor.run {
completion(ready)
}
}
},
makeSession: { URLSessionAdapter(base: TorURLSession.shared.session) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
)
}
}
/// Manages WebSocket connections to Nostr relays /// Manages WebSocket connections to Nostr relays
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
@@ -41,10 +135,11 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
private var hasMutualFavorites: Bool = false private var hasMutualFavorites: Bool = false
private var hasLocationPermission: Bool = false private var hasLocationPermission: Bool = false
private var connections: [String: URLSessionWebSocketTask] = [:] private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON) private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:] private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
@@ -69,8 +164,7 @@ final class NostrRelayManager: ObservableObject {
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared } private var shouldUseTor: Bool { dependencies.userTorEnabled() }
private var shouldUseTor: Bool { networkService.userTorEnabled }
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
@@ -82,12 +176,13 @@ final class NostrRelayManager: ObservableObject {
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
init() { init() {
hasMutualFavorites = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty self.dependencies = .live()
hasLocationPermission = LocationChannelManager.shared.permissionState == .authorized hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true) applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests // Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys self.encoder.outputFormatting = .sortedKeys
FavoritesPersistenceService.shared.$mutualFavorites dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] favorites in .sink { [weak self] favorites in
guard let self = self else { return } guard let self = self else { return }
@@ -95,7 +190,34 @@ final class NostrRelayManager: ObservableObject {
self.applyDefaultRelayPolicy() self.applyDefaultRelayPolicy()
} }
.store(in: &cancellables) .store(in: &cancellables)
LocationChannelManager.shared.$permissionState dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
let authorized = (state == .authorized)
if authorized == self.hasLocationPermission { return }
self.hasLocationPermission = authorized
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
}
internal init(dependencies: NostrRelayManagerDependencies) {
self.dependencies = dependencies
hasMutualFavorites = dependencies.hasMutualFavorites()
hasLocationPermission = dependencies.hasLocationPermission()
applyDefaultRelayPolicy(force: true)
// Deterministic JSON shape for outbound requests
self.encoder.outputFormatting = .sortedKeys
dependencies.mutualFavoritesPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] favorites in
guard let self = self else { return }
self.hasMutualFavorites = !favorites.isEmpty
self.applyDefaultRelayPolicy()
}
.store(in: &cancellables)
dependencies.locationPermissionPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] state in .sink { [weak self] state in
guard let self = self else { return } guard let self = self else { return }
@@ -110,20 +232,18 @@ final class NostrRelayManager: ObservableObject {
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor { if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect. // Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
Task.detached { dependencies.awaitTorReady { [weak self] ready in
let ready = await TorManager.shared.awaitReady() guard let self = self else { return }
await MainActor.run { if !ready {
if !ready { SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session) return
return }
} SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session) for relay in self.relays {
for relay in self.relays { self.connectToRelay(relay.url)
self.connectToRelay(relay.url)
}
} }
} }
} else { } else {
@@ -150,15 +270,14 @@ final class NostrRelayManager: ObservableObject {
/// Ensure connections exist to the given relay URLs (idempotent). /// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) { func ensureConnections(to relayUrls: [String]) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls) let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return } guard !targets.isEmpty else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer until Tor is fully ready; avoid queuing connection attempts early // Defer until Tor is fully ready; avoid queuing connection attempts early
Task.detached { [weak self] in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if ready { self.ensureConnections(to: relayUrls) }
await MainActor.run { if ready { self.ensureConnections(to: relayUrls) } }
} }
return return
} }
@@ -175,13 +294,12 @@ final class NostrRelayManager: ObservableObject {
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing // Defer sends until Tor is ready to avoid premature queueing
Task.detached { [weak self] in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if ready { self.sendEvent(event, to: relayUrls) }
await MainActor.run { if ready { self.sendEvent(event, to: relayUrls) } }
} }
return return
} }
@@ -253,24 +371,21 @@ final class NostrRelayManager: ObservableObject {
onEOSE: (() -> Void)? = nil onEOSE: (() -> Void)? = nil
) { ) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests only if a handler already exists // Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date() let now = dependencies.now()
if messageHandlers[id] != nil { if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 { if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return return
} }
} }
subscribeCoalesce[id] = now subscribeCoalesce[id] = now
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer subscription setup until Tor is ready; avoid queuing subs early // Defer subscription setup until Tor is ready; avoid queuing subs early
Task.detached { [weak self] in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if ready {
await MainActor.run { self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler)
}
} }
} }
return return
@@ -297,7 +412,7 @@ final class NostrRelayManager: ObservableObject {
for url in urls where !existingSet.contains(url) { for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
} }
for url in candidateUrls { for url in urls {
var map = self.pendingSubscriptions[url] ?? [:] var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString map[id] = messageString
self.pendingSubscriptions[url] = map self.pendingSubscriptions[url] = map
@@ -346,7 +461,7 @@ final class NostrRelayManager: ObservableObject {
relays.append(Relay(url: url)) relays.append(Relay(url: url))
existing.insert(url) existing.insert(url)
} }
if networkService.activationAllowed { if dependencies.activationAllowed() {
ensureConnections(to: Self.defaultRelays) ensureConnections(to: Self.defaultRelays)
} }
} else { } else {
@@ -356,6 +471,7 @@ final class NostrRelayManager: ObservableObject {
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
} }
messageQueueLock.lock() messageQueueLock.lock()
for index in (0..<messageQueue.count).reversed() { for index in (0..<messageQueue.count).reversed() {
@@ -400,10 +516,9 @@ final class NostrRelayManager: ObservableObject {
// Send unsubscribe to all relays // Send unsubscribe to all relays
for (relayUrl, connection) in connections { for (relayUrl, connection) in connections {
if subscriptions[relayUrl]?.contains(id) == true { if subscriptions[relayUrl]?.contains(id) == true {
subscriptions[relayUrl]?.remove(id)
connection.send(.string(messageString)) { _ in connection.send(.string(messageString)) { _ in
Task { @MainActor in // Local state is cleared before sending so callers can re-subscribe immediately.
self.subscriptions[relayUrl]?.remove(id)
}
} }
} }
} }
@@ -413,14 +528,14 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelay(_ urlString: String) { private func connectToRelay(_ urlString: String) {
// Global network policy gate // Global network policy gate
guard networkService.activationAllowed else { return } guard dependencies.activationAllowed() else { return }
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session) SecureLogger.warning("Invalid relay URL: \(urlString)", category: .session)
return return
} }
// Avoid initiating connections while app is backgrounded; we'll reconnect on foreground // Avoid initiating connections while app is backgrounded; we'll reconnect on foreground
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isForeground() { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsForeground() {
return return
} }
@@ -435,19 +550,16 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session // Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is. // If Tor is enforced but not ready, delay connection until it is.
if shouldUseTor && TorManager.shared.torEnforced && !TorManager.shared.isReady { if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
Task.detached { [weak self] in dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return } guard let self = self else { return }
let ready = await TorManager.shared.awaitReady() if ready { self.connectToRelay(urlString) }
await MainActor.run { else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
} }
return return
} }
let session = TorURLSession.shared.session let session = dependencies.makeSession()
let task = session.webSocketTask(with: url) let task = session.webSocketTask(with: url)
connections[urlString] = task connections[urlString] = task
@@ -495,7 +607,7 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl] = nil pendingSubscriptions[relayUrl] = nil
} }
private func receiveMessage(from task: URLSessionWebSocketTask, relayUrl: String) { private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
task.receive { [weak self] result in task.receive { [weak self] result in
guard let self = self else { return } guard let self = self else { return }
@@ -505,7 +617,7 @@ final class NostrRelayManager: ObservableObject {
Task.detached(priority: .utility) { Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return } guard let parsed = ParsedInbound(message) else { return }
await MainActor.run { await MainActor.run {
NostrRelayManager.shared.handleParsedMessage(parsed, from: relayUrl) self.handleParsedMessage(parsed, from: relayUrl)
} }
} }
@@ -569,7 +681,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func sendToRelay(event: NostrEvent, connection: URLSessionWebSocketTask, relayUrl: String) { private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
@@ -601,11 +713,11 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected relays[index].isConnected = isConnected
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = Date() relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
relays[index].lastDisconnectedAt = Date() relays[index].lastDisconnectedAt = dependencies.now()
} }
} }
updateConnectionStatus() updateConnectionStatus()
@@ -621,7 +733,7 @@ final class NostrRelayManager: ObservableObject {
private func handleDisconnection(relayUrl: String, error: Error) { private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
if !networkService.activationAllowed { if !dependencies.activationAllowed() {
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
@@ -666,19 +778,21 @@ final class NostrRelayManager: ObservableObject {
maxBackoffInterval maxBackoffInterval
) )
let nextReconnectTime = Date().addingTimeInterval(backoffInterval) let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime relays[index].nextReconnectTime = nextReconnectTime
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
let gen = connectionGeneration let gen = connectionGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + backoffInterval) { [weak self] in dependencies.scheduleAfter(backoffInterval) { [weak self] in
guard let self = self else { return } Task { @MainActor [weak self] in
// Ignore stale scheduled reconnects from a previous generation guard let self = self else { return }
guard gen == self.connectionGeneration else { return } // Ignore stale scheduled reconnects from a previous generation
// Check if we should still reconnect (relay might have been removed) guard gen == self.connectionGeneration else { return }
if self.relays.contains(where: { $0.url == relayUrl }) { // Check if we should still reconnect (relay might have been removed)
self.connectToRelay(relayUrl) if self.relays.contains(where: { $0.url == relayUrl }) {
self.connectToRelay(relayUrl)
}
} }
} }
} }
@@ -692,6 +806,7 @@ final class NostrRelayManager: ObservableObject {
// Reset reconnection attempts // Reset reconnection attempts
relays[index].reconnectAttempts = 0 relays[index].reconnectAttempts = 0
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
relays[index].lastError = nil
// Disconnect if connected // Disconnect if connected
if let connection = connections[relayUrl] { if let connection = connections[relayUrl] {
@@ -713,6 +828,20 @@ final class NostrRelayManager: ObservableObject {
} }
} }
var debugPendingMessageQueueCount: Int {
messageQueueLock.lock()
defer { messageQueueLock.unlock() }
return messageQueue.count
}
func debugPendingSubscriptionCount(for relayUrl: String) -> Int {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
/// Reset all relay connections /// Reset all relay connections
func resetAllConnections() { func resetAllConnections() {
disconnect() disconnect()
@@ -764,7 +893,8 @@ private enum ParsedInbound {
if array.count >= 3, if array.count >= 3,
let subId = array[1] as? String, let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any], let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) { let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
self = .event(subId: subId, event: event) self = .event(subId: subId, event: event)
return return
} }
+1 -56
View File
@@ -6,62 +6,7 @@
// //
import Foundation import Foundation
import CryptoKit import BitFoundation
// MARK: - Hex Encoding/Decoding
extension Data {
func hexEncodedString() -> String {
if self.isEmpty {
return ""
}
return self.map { String(format: "%02x", $0) }.joined()
}
func sha256Hex() -> String {
let digest = SHA256.hash(data: self)
return digest.map { String(format: "%02x", $0) }.joined()
}
/// Initialize Data from a hex string.
/// - Parameter hexString: A hex string, optionally prefixed with "0x" or "0X".
/// Whitespace is trimmed. Must have even length after prefix removal.
/// - Returns: nil if the string has odd length or contains invalid hex characters.
init?(hexString: String) {
var hex = hexString.trimmingCharacters(in: .whitespaces)
// Remove optional 0x prefix
if hex.hasPrefix("0x") || hex.hasPrefix("0X") {
hex = String(hex.dropFirst(2))
}
// Reject odd-length strings
guard hex.count % 2 == 0 else {
return nil
}
// Reject empty strings
guard !hex.isEmpty else {
self = Data()
return
}
let len = hex.count / 2
var data = Data(capacity: len)
var index = hex.startIndex
for _ in 0..<len {
let nextIndex = hex.index(index, offsetBy: 2)
guard let byte = UInt8(String(hex[index..<nextIndex]), radix: 16) else {
return nil
}
data.append(byte)
index = nextIndex
}
self = data
}
}
// MARK: - Binary Encoding Utilities // MARK: - Binary Encoding Utilities
+1
View File
@@ -343,6 +343,7 @@ struct BinaryProtocol {
} }
guard payloadLength >= 0 else { return nil } guard payloadLength >= 0 else { return nil }
guard payloadLength <= FileTransferLimits.maxFramedFileBytes else { return nil }
guard let senderID = readData(senderIDSize) else { return nil } guard let senderID = readData(senderIDSize) else { return nil }
+1
View File
@@ -60,6 +60,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
// MARK: - Message Types // MARK: - Message Types
+123 -107
View File
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import Combine import Combine
@@ -7,7 +8,6 @@ import CryptoKit
import UIKit import UIKit
#endif #endif
/// BLEService Bluetooth Mesh Transport /// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI. /// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`). /// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -252,7 +252,8 @@ final class BLEService: NSObject {
init( init(
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true
) { ) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge self.idBridge = idBridge
@@ -295,22 +296,23 @@ final class BLEService: NSObject {
// Tag BLE queue for re-entrancy detection // Tag BLE queue for re-entrancy detection
bleQueue.setSpecific(key: bleQueueKey, value: ()) bleQueue.setSpecific(key: bleQueueKey, value: ())
// Initialize BLE on background queue to prevent main thread blocking if initializeBluetoothManagers {
// This prevents app freezes during BLE operations // Initialize BLE on background queue to prevent main thread blocking.
#if os(iOS) #if os(iOS)
let centralOptions: [String: Any] = [ let centralOptions: [String: Any] = [
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
] ]
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions) centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
let peripheralOptions: [String: Any] = [ let peripheralOptions: [String: Any] = [
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
] ]
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
#else #else
centralManager = CBCentralManager(delegate: self, queue: bleQueue) centralManager = CBCentralManager(delegate: self, queue: bleQueue)
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue) peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
#endif #endif
}
// Single maintenance timer for all periodic tasks (dispatch-based for determinism) // Single maintenance timer for all periodic tasks (dispatch-based for determinism)
let timer = DispatchSource.makeTimerSource(queue: bleQueue) let timer = DispatchSource.makeTimerSource(queue: bleQueue)
@@ -524,7 +526,7 @@ final class BLEService: NSObject {
func stopServices() { func stopServices() {
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
let leavePacket = BitchatPacket( var leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
@@ -534,6 +536,10 @@ final class BLEService: NSObject {
ttl: messageTTL ttl: messageTTL
) )
if let signed = noiseService.signPacket(leavePacket) {
leavePacket = signed
}
// Send immediately to all connected peers (synchronized access to BLE state) // Send immediately to all connected peers (synchronized access to BLE state)
if let data = leavePacket.toBinaryData(padding: false) { if let data = leavePacket.toBinaryData(padding: false) {
let leavePriority = priority(for: leavePacket, data: data) let leavePriority = priority(for: leavePacket, data: data)
@@ -730,7 +736,7 @@ final class BLEService: NSObject {
return return
} }
let packet = BitchatPacket( var packet = BitchatPacket(
type: MessageType.fileTransfer.rawValue, type: MessageType.fileTransfer.rawValue,
senderID: self.myPeerIDData, senderID: self.myPeerIDData,
recipientID: nil, recipientID: nil,
@@ -741,6 +747,13 @@ final class BLEService: NSObject {
version: 2 version: 2
) )
if let signed = self.noiseService.signPacket(packet) {
packet = signed
} else {
SecureLogger.error("❌ Failed to sign file broadcast packet", category: .security)
return
}
let senderHex = packet.senderID.hexEncodedString() let senderHex = packet.senderID.hexEncodedString()
let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)" let dedupID = "\(senderHex)-\(packet.timestamp)-\(packet.type)"
self.messageDeduplicator.markProcessed(dedupID) self.messageDeduplicator.markProcessed(dedupID)
@@ -820,21 +833,21 @@ final class BLEService: NSObject {
} }
} }
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID) -> Bool { private enum ConnectionSource {
case peripheral(String)
case central(String)
case unknown
}
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000) let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
let messageType = MessageType(rawValue: packet.type)
// 1. Timestamp Validation (Skipped for valid RSR packets)
let isRSR = packet.isRSR let isRSR = packet.isRSR
// Treat TTL=0 as legacy RSR if not REQUEST_SYNC
// (Legacy clients send responses with TTL=0 but no flag)
let isLegacyRSR = packet.ttl == 0 && messageType != .requestSync
var skipTimestampCheck = false var skipTimestampCheck = false
if isRSR || isLegacyRSR { if isRSR {
// We check both explicit RSR flag AND legacy TTL=0 packets
if requestSyncManager.isValidResponse(from: peerID, isRSR: true) { if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {
SecureLogger.debug("Valid RSR packet (legacy=\(isLegacyRSR)) from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security) SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
skipTimestampCheck = true skipTimestampCheck = true
} else { } else {
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security) SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
@@ -843,8 +856,7 @@ final class BLEService: NSObject {
} }
if !skipTimestampCheck { if !skipTimestampCheck {
// Enforce timestamp check for normal packets (2 minutes skew) let maxSkew: UInt64 = 120_000
let maxSkew: UInt64 = 120_000 // 2 minutes in ms
let packetTime = packet.timestamp let packetTime = packet.timestamp
let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime) let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)
@@ -1165,13 +1177,6 @@ final class BLEService: NSObject {
break break
} }
} }
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
} else if packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
} }
guard accepted else { guard accepted else {
@@ -1218,38 +1223,16 @@ final class BLEService: NSObject {
// BCH-01-002: Enforce storage quota before saving // BCH-01-002: Enforce storage quota before saving
enforceIncomingFilesQuota(reservingBytes: filePacket.content.count) enforceIncomingFilesQuota(reservingBytes: filePacket.content.count)
let fallbackExt = mime.defaultExtension
let subdirectory: String
switch mime.category {
case .audio:
subdirectory = "voicenotes/incoming"
case .image:
subdirectory = "images/incoming"
case .file:
subdirectory = "files/incoming"
}
guard let destination = saveIncomingFile( guard let destination = saveIncomingFile(
data: filePacket.content, data: filePacket.content,
preferredName: filePacket.fileName, preferredName: filePacket.fileName,
subdirectory: subdirectory, subdirectory: "\(mime.category.mediaDir)/incoming",
fallbackExtension: fallbackExt, fallbackExtension: mime.defaultExtension,
defaultPrefix: mime.category.rawValue defaultPrefix: mime.category.rawValue
) else { ) else {
return return
} }
let marker: String
let fileName = destination.lastPathComponent
switch mime.category {
case .audio:
marker = "[voice] \(fileName)"
case .image:
marker = "[image] \(fileName)"
case .file:
marker = "[file] \(fileName)"
}
let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID let isPrivateMessage = PeerID(hexData: packet.recipientID) == myPeerID
if isPrivateMessage { if isPrivateMessage {
@@ -1259,7 +1242,7 @@ final class BLEService: NSObject {
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage( let message = BitchatMessage(
sender: senderNickname, sender: senderNickname,
content: marker, content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts, timestamp: ts,
isRelay: false, isRelay: false,
originalSender: nil, originalSender: nil,
@@ -1372,7 +1355,7 @@ final class BLEService: NSObject {
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters) let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
candidate = candidate.components(separatedBy: invalid).joined(separator: "_") candidate = candidate.components(separatedBy: invalid).joined(separator: "_")
candidate = candidate.trimmingCharacters(in: .whitespacesAndNewlines) candidate = candidate.trimmed
if candidate.isEmpty { candidate = defaultName } if candidate.isEmpty { candidate = defaultName }
// Security: Reject dotfiles (hidden file attacks) // Security: Reject dotfiles (hidden file attacks)
@@ -2047,26 +2030,28 @@ extension BLEService {
#if DEBUG #if DEBUG
// Test-only helper to inject packets into the receive pipeline // Test-only helper to inject packets into the receive pipeline
extension BLEService { extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID) { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true) {
// Ensure the synthetic peer is known and marked verified for public-message tests if preseedPeer {
let normalizedID = PeerID(hexData: packet.senderID) // Ensure the synthetic peer is known and marked verified for public-message tests
collectionsQueue.sync(flags: .barrier) { let normalizedID = PeerID(hexData: packet.senderID)
if peers[normalizedID] == nil { collectionsQueue.sync(flags: .barrier) {
peers[normalizedID] = PeerInfo( if peers[normalizedID] == nil {
peerID: normalizedID, peers[normalizedID] = PeerInfo(
nickname: "TestPeer_\(fromPeerID.id.prefix(4))", peerID: normalizedID,
isConnected: true, nickname: "TestPeer_\(fromPeerID.id.prefix(4))",
noisePublicKey: packet.senderID, isConnected: true,
signingPublicKey: nil, noisePublicKey: packet.senderID,
isVerifiedNickname: true, signingPublicKey: nil,
lastSeen: Date() isVerifiedNickname: true,
) lastSeen: Date()
} else { )
var p = peers[normalizedID]! } else {
p.isConnected = true var p = peers[normalizedID]!
p.isVerifiedNickname = true p.isConnected = true
p.lastSeen = Date() p.isVerifiedNickname = true
peers[normalizedID] = p p.lastSeen = Date()
peers[normalizedID] = p
}
} }
} }
handleReceivedPacket(packet, from: fromPeerID) handleReceivedPacket(packet, from: fromPeerID)
@@ -2193,17 +2178,45 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session) SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
} }
// Codex review identified TOCTOU in this patch.
// Enforce per-link sender binding immediately within the same notification batch.
// NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
var boundPeerID: PeerID? = state.peerID
for frame in result.frames { for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else { guard let packet = BinaryProtocol.decode(frame) else {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ") let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session) SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue continue
} }
// Validate packet (Timestamp/RSR) before processing
let senderID = PeerID(hexData: packet.senderID) let claimedSenderID = PeerID(hexData: packet.senderID)
if !validatePacket(packet, from: senderID) {
let trustedSenderID: PeerID?
if let knownPeerID = boundPeerID {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {
continue continue
} }
// If this is a direct-link announce, bind immediately for the remainder of this batch.
if boundPeerID == nil,
packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
boundPeerID = claimedSenderID
state.peerID = claimedSenderID
peripherals[peripheralUUID] = state
}
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID) processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
} }
} }
@@ -2618,22 +2631,33 @@ extension BLEService: CBPeripheralManagerDelegate {
if let packet = BinaryProtocol.decode(combined) { if let packet = BinaryProtocol.decode(combined) {
// Clear buffer on success // Clear buffer on success
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
let senderID = PeerID(hexData: packet.senderID)
// Validate packet (Timestamp/RSR) let claimedSenderID = PeerID(hexData: packet.senderID)
if !validatePacket(packet, from: senderID) {
let trustedSenderID: PeerID?
if let knownPeerID = centralToPeerID[centralUUID] {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {
continue continue
} }
if packet.type != MessageType.announce.rawValue { if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session) SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
} }
if !subscribedCentrals.contains(sorted[0].central) { if !subscribedCentrals.contains(sorted[0].central) {
subscribedCentrals.append(sorted[0].central) subscribedCentrals.append(sorted[0].central)
} }
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { if packet.ttl == messageTTL {
centralToPeerID[centralUUID] = senderID centralToPeerID[centralUUID] = claimedSenderID
refreshLocalTopology() refreshLocalTopology()
} }
// Record ingress link for last-hop suppression then process // Record ingress link for last-hop suppression then process
@@ -2641,14 +2665,14 @@ extension BLEService: CBPeripheralManagerDelegate {
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
} }
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: claimedSenderID)
} else { } else {
// Record ingress link for last-hop suppression then process // Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet) let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date()) self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
} }
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: claimedSenderID)
} }
} else { } else {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
@@ -3809,7 +3833,7 @@ extension BLEService {
let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey) let derivedFromKey = PeerID(publicKey: announcement.noisePublicKey)
if derivedFromKey != peerID { if derivedFromKey != peerID {
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security) SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
} }
// Don't add ourselves as a peer // Don't add ourselves as a peer
@@ -4057,16 +4081,13 @@ extension BLEService {
} }
} }
} }
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
} }
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast) guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
let isBroadcastRecipient: Bool = { let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true } guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF } return r.count == 8 && r.allSatisfy { $0 == 0xFF }
@@ -4075,11 +4096,6 @@ extension BLEService {
gossipSyncManager?.onPublicPacketSeen(packet) gossipSyncManager?.onPublicPacketSeen(packet)
} }
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let content = String(data: packet.payload, encoding: .utf8) else { guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session) SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return return
+17
View File
@@ -191,5 +191,22 @@ enum MimeType: CaseIterable, Hashable {
extension MimeType { extension MimeType {
enum Category: String { enum Category: String {
case audio, image, file case audio, image, file
/// Ends with a space
var messagePrefix: String {
switch self {
case .audio: "[voice] "
case .image: "[image] "
case .file: "[file] "
}
}
var mediaDir: String {
switch self {
case .audio: "voicenotes"
case .image: "images"
case .file: "files"
}
}
} }
} }
+5 -4
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
/// Result of command processing /// Result of command processing
enum CommandResult { enum CommandResult {
@@ -166,7 +167,7 @@ final class CommandProcessor {
} }
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult { private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmed
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(command) <nickname>") return .error(message: "usage: /\(command) <nickname>")
} }
@@ -209,7 +210,7 @@ final class CommandProcessor {
} }
private func handleBlock(_ args: String) -> CommandResult { private func handleBlock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmed
if targetName.isEmpty { if targetName.isEmpty {
// List blocked users (mesh) and geohash (Nostr) blocks // List blocked users (mesh) and geohash (Nostr) blocks
@@ -284,7 +285,7 @@ final class CommandProcessor {
} }
private func handleUnblock(_ args: String) -> CommandResult { private func handleUnblock(_ args: String) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmed
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /unblock <nickname>") return .error(message: "usage: /unblock <nickname>")
} }
@@ -311,7 +312,7 @@ final class CommandProcessor {
} }
private func handleFavorite(_ args: String, add: Bool) -> CommandResult { private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
let targetName = args.trimmingCharacters(in: .whitespaces) let targetName = args.trimmed
guard !targetName.isEmpty else { guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>") return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
} }
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
+118 -25
View File
@@ -13,6 +13,25 @@ import Combine
import BitLogger import BitLogger
import Tor import Tor
protocol GeohashPresenceTimerProtocol: AnyObject {
var isValid: Bool { get }
func invalidate()
}
private final class GeohashPresenceTimerAdapter: GeohashPresenceTimerProtocol {
private let base: Timer
init(base: Timer) {
self.base = base
}
var isValid: Bool { base.isValid }
func invalidate() {
base.invalidate()
}
}
/// Service that coordinates the broadcasting of presence heartbeats. /// Service that coordinates the broadcasting of presence heartbeats.
/// ///
/// Behavior: /// Behavior:
@@ -25,18 +44,27 @@ final class GeohashPresenceService: ObservableObject {
static let shared = GeohashPresenceService() static let shared = GeohashPresenceService()
private var subscriptions = Set<AnyCancellable>() private var subscriptions = Set<AnyCancellable>()
private var heartbeatTimer: Timer? private var heartbeatTimer: GeohashPresenceTimerProtocol?
private let idBridge = NostrIdentityBridge() private let availableChannelsProvider: () -> [GeohashChannel]
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
private let torReadyPublisher: AnyPublisher<Void, Never>
private let torIsReady: () -> Bool
private let torIsForeground: () -> Bool
private let deriveIdentity: (String) throws -> NostrIdentity
private let relayLookup: (String, Int) -> [String]
private let relaySender: (NostrEvent, [String]) -> Void
private let sleeper: (UInt64) async -> Void
private let scheduleTimer: (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol
// MARK: - Constants // MARK: - Constants
// Loop interval range in seconds // Loop interval range in seconds
private let loopMinInterval: TimeInterval = 40.0 private let loopMinInterval: TimeInterval
private let loopMaxInterval: TimeInterval = 80.0 private let loopMaxInterval: TimeInterval
// Per-broadcast decorrelation delay range in seconds // Per-broadcast decorrelation delay range in seconds
private let burstMinDelay: TimeInterval = 2.0 private let burstMinDelay: TimeInterval
private let burstMaxDelay: TimeInterval = 5.0 private let burstMaxDelay: TimeInterval
// Privacy: Only broadcast to these levels // Privacy: Only broadcast to these levels
private let allowedPrecisions: Set<Int> = [ private let allowedPrecisions: Set<Int> = [
@@ -46,6 +74,74 @@ final class GeohashPresenceService: ObservableObject {
] ]
private init() { private init() {
let idBridge = NostrIdentityBridge()
self.availableChannelsProvider = { LocationStateManager.shared.availableChannels }
self.locationChanges = LocationStateManager.shared.$availableChannels.eraseToAnyPublisher()
self.torReadyPublisher = NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.map { _ in () }
.eraseToAnyPublisher()
self.torIsReady = { TorManager.shared.isReady }
self.torIsForeground = { TorManager.shared.isForeground() }
self.deriveIdentity = { try idBridge.deriveIdentity(forGeohash: $0) }
self.relayLookup = { geohash, count in
GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count)
}
self.relaySender = { event, relays in
NostrRelayManager.shared.sendEvent(event, to: relays)
}
self.sleeper = { nanoseconds in
try? await Task.sleep(nanoseconds: nanoseconds)
}
self.scheduleTimer = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
}
self.loopMinInterval = 40.0
self.loopMaxInterval = 80.0
self.burstMinDelay = 2.0
self.burstMaxDelay = 5.0
setupObservers()
}
internal init(
availableChannelsProvider: @escaping () -> [GeohashChannel],
locationChanges: AnyPublisher<[GeohashChannel], Never>,
torReadyPublisher: AnyPublisher<Void, Never>,
torIsReady: @escaping () -> Bool,
torIsForeground: @escaping () -> Bool,
deriveIdentity: @escaping (String) throws -> NostrIdentity,
relayLookup: @escaping (String, Int) -> [String],
relaySender: @escaping (NostrEvent, [String]) -> Void,
sleeper: @escaping (UInt64) async -> Void = { nanoseconds in try? await Task.sleep(nanoseconds: nanoseconds) },
scheduleTimer: @escaping (TimeInterval, @escaping () -> Void) -> GeohashPresenceTimerProtocol = { interval, action in
GeohashPresenceTimerAdapter(
base: Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
)
},
loopMinInterval: TimeInterval = 40.0,
loopMaxInterval: TimeInterval = 80.0,
burstMinDelay: TimeInterval = 2.0,
burstMaxDelay: TimeInterval = 5.0
) {
self.availableChannelsProvider = availableChannelsProvider
self.locationChanges = locationChanges
self.torReadyPublisher = torReadyPublisher
self.torIsReady = torIsReady
self.torIsForeground = torIsForeground
self.deriveIdentity = deriveIdentity
self.relayLookup = relayLookup
self.relaySender = relaySender
self.sleeper = sleeper
self.scheduleTimer = scheduleTimer
self.loopMinInterval = loopMinInterval
self.loopMaxInterval = loopMaxInterval
self.burstMinDelay = burstMinDelay
self.burstMaxDelay = burstMaxDelay
setupObservers() setupObservers()
} }
@@ -57,7 +153,7 @@ final class GeohashPresenceService: ObservableObject {
private func setupObservers() { private func setupObservers() {
// Monitor location channel changes // Monitor location channel changes
LocationStateManager.shared.$availableChannels locationChanges
.dropFirst() .dropFirst()
.sink { [weak self] _ in .sink { [weak self] _ in
self?.handleLocationChange() self?.handleLocationChange()
@@ -65,28 +161,28 @@ final class GeohashPresenceService: ObservableObject {
.store(in: &subscriptions) .store(in: &subscriptions)
// Monitor Tor readiness to kick off heartbeat if it was stalled // Monitor Tor readiness to kick off heartbeat if it was stalled
NotificationCenter.default.publisher(for: .TorDidBecomeReady) torReadyPublisher
.sink { [weak self] _ in .sink { [weak self] _ in
self?.handleConnectivityChange() self?.handleConnectivityChange()
} }
.store(in: &subscriptions) .store(in: &subscriptions)
} }
private func handleLocationChange() { func handleLocationChange() {
// When location changes, we trigger an immediate (but slightly delayed) heartbeat // When location changes, we trigger an immediate (but slightly delayed) heartbeat
// to announce presence in the new zone, then reset the loop. // to announce presence in the new zone, then reset the loop.
SecureLogger.debug("Presence: location changed, scheduling update", category: .session) SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
heartbeatTimer?.invalidate() heartbeatTimer?.invalidate()
// Small delay to allow location state to settle // Small delay to allow location state to settle
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in heartbeatTimer = scheduleTimer(5.0) { [weak self] in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.performHeartbeat() self?.performHeartbeat()
} }
} }
} }
private func handleConnectivityChange() { func handleConnectivityChange() {
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session) SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
// If we were waiting for network, do it now // If we were waiting for network, do it now
if heartbeatTimer == nil || !heartbeatTimer!.isValid { if heartbeatTimer == nil || !heartbeatTimer!.isValid {
@@ -94,33 +190,33 @@ final class GeohashPresenceService: ObservableObject {
} }
} }
private func scheduleNextHeartbeat() { func scheduleNextHeartbeat() {
heartbeatTimer?.invalidate() heartbeatTimer?.invalidate()
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval) let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in heartbeatTimer = scheduleTimer(interval) { [weak self] in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
self?.performHeartbeat() self?.performHeartbeat()
} }
} }
} }
private func performHeartbeat() { func performHeartbeat() {
// Always schedule next loop first ensures continuity even if this one fails/skips // Always schedule next loop first ensures continuity even if this one fails/skips
defer { scheduleNextHeartbeat() } defer { scheduleNextHeartbeat() }
// 1. Check preconditions // 1. Check preconditions
guard TorManager.shared.isReady else { guard torIsReady() else {
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session) SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
return return
} }
// App must be active (or at least we shouldn't broadcast if in background, usually) // App must be active (or at least we shouldn't broadcast if in background, usually)
if !TorManager.shared.isForeground() { if !torIsForeground() {
return return
} }
// 2. Get channels // 2. Get channels
let channels = LocationStateManager.shared.availableChannels let channels = availableChannelsProvider()
guard !channels.isEmpty else { return } guard !channels.isEmpty else { return }
// 3. Filter and broadcast // 3. Filter and broadcast
@@ -136,16 +232,16 @@ final class GeohashPresenceService: ObservableObject {
// Random delay for decorrelation // Random delay for decorrelation
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay) let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
let nanoseconds = UInt64(delay * 1_000_000_000) let nanoseconds = UInt64(delay * 1_000_000_000)
try? await Task.sleep(nanoseconds: nanoseconds) await self.sleeper(nanoseconds)
self.broadcastPresence(for: channel.geohash) self.broadcastPresence(for: channel.geohash)
} }
} }
} }
private func broadcastPresence(for geohash: String) { func broadcastPresence(for geohash: String) {
do { do {
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else { guard let identity = try? deriveIdentity(geohash) else {
return return
} }
@@ -155,13 +251,10 @@ final class GeohashPresenceService: ObservableObject {
) )
// Send via RelayManager // Send via RelayManager
let targetRelays = GeoRelayDirectory.shared.closestRelays( let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
toGeohash: geohash,
count: TransportConfig.nostrGeoRelayCount
)
if !targetRelays.isEmpty { if !targetRelays.isEmpty {
NostrRelayManager.shared.sendEvent(event, to: targetRelays) relaySender(event, targetRelays)
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session) SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
} }
} catch { } catch {
+2 -3
View File
@@ -63,7 +63,7 @@ final class LocationNotesManager: ObservableObject {
var displayName: String { var displayName: String {
let suffix = String(pubkey.suffix(4)) let suffix = String(pubkey.suffix(4))
if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if let nick = nickname?.trimmedOrNilIfEmpty {
return "\(nick)#\(suffix)" return "\(nick)#\(suffix)"
} }
return "anon#\(suffix)" return "anon#\(suffix)"
@@ -199,8 +199,7 @@ final class LocationNotesManager: ObservableObject {
/// Send a location note for the current geohash using the per-geohash identity. /// Send a location note for the current geohash using the per-geohash identity.
func send(content: String, nickname: String) { func send(content: String, nickname: String) {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard let trimmed = content.trimmedOrNilIfEmpty else { return }
guard !trimmed.isEmpty else { return }
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
guard !relays.isEmpty else { guard !relays.isEmpty else {
state = .noRelays state = .noRelays
+100 -15
View File
@@ -5,6 +5,79 @@ import Combine
#if os(iOS) || os(macOS) #if os(iOS) || os(macOS)
import CoreLocation import CoreLocation
protocol LocationStateManaging: AnyObject {
var delegate: CLLocationManagerDelegate? { get set }
var desiredAccuracy: CLLocationAccuracy { get set }
var distanceFilter: CLLocationDistance { get set }
var authorizationStatus: CLAuthorizationStatus { get }
func requestWhenInUseAuthorization()
func requestLocation()
func startUpdatingLocation()
func stopUpdatingLocation()
}
protocol LocationStateGeocoding: AnyObject {
func cancelGeocode()
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
)
}
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
private let base = CLLocationManager()
var delegate: CLLocationManagerDelegate? {
get { base.delegate }
set { base.delegate = newValue }
}
var desiredAccuracy: CLLocationAccuracy {
get { base.desiredAccuracy }
set { base.desiredAccuracy = newValue }
}
var distanceFilter: CLLocationDistance {
get { base.distanceFilter }
set { base.distanceFilter = newValue }
}
var authorizationStatus: CLAuthorizationStatus {
base.authorizationStatus
}
func requestWhenInUseAuthorization() {
base.requestWhenInUseAuthorization()
}
func requestLocation() {
base.requestLocation()
}
func startUpdatingLocation() {
base.startUpdatingLocation()
}
func stopUpdatingLocation() {
base.stopUpdatingLocation()
}
}
private final class CLGeocoderAdapter: LocationStateGeocoding {
private let base = CLGeocoder()
func cancelGeocode() {
base.cancelGeocode()
}
func reverseGeocodeLocation(
_ location: CLLocation,
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
) {
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
}
}
/// Unified manager for location-based channel state including: /// Unified manager for location-based channel state including:
/// - CoreLocation permissions and one-shot location retrieval /// - CoreLocation permissions and one-shot location retrieval
/// - Geohash channel computation from coordinates /// - Geohash channel computation from coordinates
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Private Properties (CoreLocation) // MARK: - Private Properties (CoreLocation)
private let cl = CLLocationManager() private let cl: LocationStateManaging
private let geocoder = CLGeocoder() private let geocoder: LocationStateGeocoding
private var lastLocation: CLLocation? private var lastLocation: CLLocation?
private var refreshTimer: Timer? private var refreshTimer: Timer?
private var isGeocoding: Bool = false private var isGeocoding: Bool = false
@@ -73,6 +146,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private override init() { private override init() {
self.storage = .standard self.storage = .standard
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init() super.init()
// Skip CoreLocation setup in test environments // Skip CoreLocation setup in test environments
@@ -92,10 +167,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
/// Internal initializer for testing with custom storage /// Internal initializer for testing with custom storage
init(storage: UserDefaults) { init(storage: UserDefaults) {
self.storage = storage self.storage = storage
self.cl = CLLocationManagerAdapter()
self.geocoder = CLGeocoderAdapter()
super.init() super.init()
loadPersistedState() loadPersistedState()
} }
internal init(
storage: UserDefaults,
locationManager: LocationStateManaging,
geocoder: LocationStateGeocoding,
shouldInitializeCoreLocation: Bool
) {
self.storage = storage
self.cl = locationManager
self.geocoder = geocoder
super.init()
loadPersistedState()
guard shouldInitializeCoreLocation else { return }
cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
initializePermissionState()
}
private func loadPersistedState() { private func loadPersistedState() {
// Load selected channel // Load selected channel
if let data = storage.data(forKey: selectedChannelKey), if let data = storage.data(forKey: selectedChannelKey),
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
} }
private func initializePermissionState() { private func initializePermissionState() {
let status: CLAuthorizationStatus let status = cl.authorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status) updatePermissionState(from: status)
// Fall back to persisted teleport state if no location authorization // Fall back to persisted teleport state if no location authorization
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
// MARK: - Public API (Permissions & Location) // MARK: - Public API (Permissions & Location)
func enableLocationChannels() { func enableLocationChannels() {
let status: CLAuthorizationStatus let status = cl.authorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
} else {
status = CLLocationManager.authorizationStatus()
}
switch status { switch status {
case .notDetermined: case .notDetermined:
cl.requestWhenInUseAuthorization() cl.requestWhenInUseAuthorization()
@@ -512,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
private static func normalizeGeohash(_ s: String) -> String { private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s return s
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmed
.lowercased() .lowercased()
.replacingOccurrences(of: "#", with: "") .replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) } .filter { allowed.contains($0) }
@@ -145,7 +145,7 @@ enum ContentNormalizer {
} }
// Trim and collapse whitespace // Trim and collapse whitespace
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = simplified.trimmed
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
// Take prefix and hash // Take prefix and hash
@@ -6,6 +6,7 @@
// This is free and unencumbered software released into the public domain. // This is free and unencumbered software released into the public domain.
// //
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
+1
View File
@@ -1,4 +1,5 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
+80 -18
View File
@@ -3,6 +3,27 @@ import BitLogger
import Combine import Combine
import Tor import Tor
@MainActor
protocol NetworkActivationTorControlling: AnyObject {
func setAutoStartAllowed(_ allowed: Bool)
func startIfNeeded()
func shutdownCompletely()
}
@MainActor
protocol NetworkActivationRelayControlling: AnyObject {
func connect()
func disconnect()
}
protocol NetworkActivationProxyControlling: AnyObject {
func setProxyMode(useTor: Bool)
}
extension TorManager: NetworkActivationTorControlling {}
extension NostrRelayManager: NetworkActivationRelayControlling {}
extension TorURLSession: NetworkActivationProxyControlling {}
/// Coordinates when the app is allowed to start Tor and connect to Nostr relays. /// Coordinates when the app is allowed to start Tor and connect to Nostr relays.
/// Policy: permit start when either location permissions are authorized OR /// Policy: permit start when either location permissions are authorized OR
/// there exists at least one mutual favorite. Otherwise, do not start. /// there exists at least one mutual favorite. Otherwise, do not start.
@@ -17,14 +38,55 @@ final class NetworkActivationService: ObservableObject {
private var started = false private var started = false
private let torPreferenceKey = "networkActivationService.userTorEnabled" private let torPreferenceKey = "networkActivationService.userTorEnabled"
private var torAutoStartDesired: Bool = false private var torAutoStartDesired: Bool = false
private let storage: UserDefaults
private let locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>
private let mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
private init() {} private init() {
storage = .standard
locationPermissionPublisher = LocationChannelManager.shared.$permissionState.eraseToAnyPublisher()
mutualFavoritesPublisher = FavoritesPersistenceService.shared.$mutualFavorites.eraseToAnyPublisher()
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
internal init(
storage: UserDefaults,
locationPermissionPublisher: AnyPublisher<LocationChannelManager.PermissionState, Never>,
mutualFavoritesPublisher: AnyPublisher<Set<Data>, Never>,
permissionProvider: @escaping () -> LocationChannelManager.PermissionState,
mutualFavoritesProvider: @escaping () -> Set<Data>,
torController: NetworkActivationTorControlling,
relayController: NetworkActivationRelayControlling,
proxyController: NetworkActivationProxyControlling,
notificationCenter: NotificationCenter = .default
) {
self.storage = storage
self.locationPermissionPublisher = locationPermissionPublisher
self.mutualFavoritesPublisher = mutualFavoritesPublisher
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
func start() { func start() {
guard !started else { return } guard !started else { return }
started = true started = true
if let stored = UserDefaults.standard.object(forKey: torPreferenceKey) as? Bool { if let stored = storage.object(forKey: torPreferenceKey) as? Bool {
userTorEnabled = stored userTorEnabled = stored
} else { } else {
userTorEnabled = true userTorEnabled = true
@@ -34,16 +96,16 @@ final class NetworkActivationService: ObservableObject {
let allowed = basePolicyAllowed() let allowed = basePolicyAllowed()
activationAllowed = allowed activationAllowed = allowed
torAutoStartDesired = allowed && userTorEnabled torAutoStartDesired = allowed && userTorEnabled
TorManager.shared.setAutoStartAllowed(torAutoStartDesired) torController.setAutoStartAllowed(torAutoStartDesired)
applyTorState(torDesired: torAutoStartDesired) applyTorState(torDesired: torAutoStartDesired)
if allowed { if allowed {
NostrRelayManager.shared.connect() relayController.connect()
} else { } else {
NostrRelayManager.shared.disconnect() relayController.disconnect()
} }
// React to location permission changes // React to location permission changes
LocationChannelManager.shared.$permissionState locationPermissionPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.reevaluate() self?.reevaluate()
@@ -51,7 +113,7 @@ final class NetworkActivationService: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
// React to mutual favorites changes // React to mutual favorites changes
FavoritesPersistenceService.shared.$mutualFavorites mutualFavoritesPublisher
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.reevaluate() self?.reevaluate()
@@ -62,8 +124,8 @@ final class NetworkActivationService: ObservableObject {
func setUserTorEnabled(_ enabled: Bool) { func setUserTorEnabled(_ enabled: Bool) {
guard enabled != userTorEnabled else { return } guard enabled != userTorEnabled else { return }
userTorEnabled = enabled userTorEnabled = enabled
UserDefaults.standard.set(enabled, forKey: torPreferenceKey) storage.set(enabled, forKey: torPreferenceKey)
NotificationCenter.default.post( notificationCenter.post(
name: .TorUserPreferenceChanged, name: .TorUserPreferenceChanged,
object: nil, object: nil,
userInfo: ["enabled": enabled] userInfo: ["enabled": enabled]
@@ -82,33 +144,33 @@ final class NetworkActivationService: ObservableObject {
} }
if statusChanged || torChanged { if statusChanged || torChanged {
torAutoStartDesired = torDesired torAutoStartDesired = torDesired
TorManager.shared.setAutoStartAllowed(torDesired) torController.setAutoStartAllowed(torDesired)
applyTorState(torDesired: torDesired) applyTorState(torDesired: torDesired)
} }
if allowed { if allowed {
if torChanged { if torChanged {
// Reset relay sockets when switching transport path (Tor direct) // Reset relay sockets when switching transport path (Tor direct)
NostrRelayManager.shared.disconnect() relayController.disconnect()
} }
NostrRelayManager.shared.connect() relayController.connect()
} else if statusChanged { } else if statusChanged {
NostrRelayManager.shared.disconnect() relayController.disconnect()
} }
} }
private func basePolicyAllowed() -> Bool { private func basePolicyAllowed() -> Bool {
let permOK = LocationChannelManager.shared.permissionState == .authorized let permOK = permissionProvider() == .authorized
let hasMutual = !FavoritesPersistenceService.shared.mutualFavorites.isEmpty let hasMutual = !mutualFavoritesProvider().isEmpty
return permOK || hasMutual return permOK || hasMutual
} }
private func applyTorState(torDesired: Bool) { private func applyTorState(torDesired: Bool) {
TorURLSession.shared.setProxyMode(useTor: torDesired) proxyController.setProxyMode(useTor: torDesired)
if torDesired { if torDesired {
TorManager.shared.startIfNeeded() torController.startIfNeeded()
} else { } else {
TorManager.shared.shutdownCompletely() torController.shutdownCompletely()
} }
} }
} }
@@ -83,6 +83,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import CryptoKit import CryptoKit
+53 -13
View File
@@ -1,9 +1,36 @@
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
// Minimal Nostr transport conforming to Transport for offline sending // Minimal Nostr transport conforming to Transport for offline sending
final class NostrTransport: Transport, @unchecked Sendable { final class NostrTransport: Transport, @unchecked Sendable {
struct Dependencies {
let notificationCenter: NotificationCenter
let loadFavorites: @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship]
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
Dependencies(
notificationCenter: .default,
loadFavorites: { FavoritesPersistenceService.shared.favorites },
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
}
}
// Provide BLE short peer ID for BitChat embedding // Provide BLE short peer ID for BitChat embedding
var senderPeerID = PeerID(str: "") var senderPeerID = PeerID(str: "")
@@ -17,20 +44,27 @@ final class NostrTransport: Transport, @unchecked Sendable {
private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private let dependencies: Dependencies
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe) // Reachability Cache (thread-safe)
private var reachablePeers: Set<PeerID> = [] private var reachablePeers: Set<PeerID> = []
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent) private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
@MainActor @MainActor
init(keychain: KeychainManagerProtocol, idBridge: NostrIdentityBridge) { init(
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge self.idBridge = idBridge
self.dependencies = dependencies ?? .live(idBridge: idBridge)
setupObservers() setupObservers()
// Synchronously warm the cache to avoid startup race // Synchronously warm the cache to avoid startup race
let favorites = FavoritesPersistenceService.shared.favorites let favorites = self.dependencies.loadFavorites()
let reachable = favorites.values let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil } .filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) } .map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -40,8 +74,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
} }
} }
deinit {
if let favoriteStatusObserver {
dependencies.notificationCenter.removeObserver(favoriteStatusObserver)
}
}
private func setupObservers() { private func setupObservers() {
NotificationCenter.default.addObserver( favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged, forName: .favoriteStatusChanged,
object: nil, object: nil,
queue: nil queue: nil
@@ -52,7 +92,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
private func refreshReachablePeers() { private func refreshReachablePeers() {
Task { @MainActor in Task { @MainActor in
let favorites = FavoritesPersistenceService.shared.favorites let favorites = dependencies.loadFavorites()
let reachable = favorites.values let reachable = favorites.values
.filter { $0.peerNostrPublicKey != nil } .filter { $0.peerNostrPublicKey != nil }
.map { PeerID(publicKey: $0.peerNoisePublicKey) } .map { PeerID(publicKey: $0.peerNoisePublicKey) }
@@ -120,7 +160,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub), let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
@@ -143,7 +183,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub), let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let senderIdentity = try? dependencies.currentIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session) SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
@@ -159,7 +199,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub), let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
@@ -229,9 +269,9 @@ extension NostrTransport {
return return
} }
if registerPending { if registerPending {
NostrRelayManager.registerPendingGiftWrap(id: event.id) dependencies.registerPendingGiftWrap(event.id)
} }
NostrRelayManager.shared.sendEvent(event) dependencies.sendEvent(event)
} }
/// Must be called within a barrier on `queue` /// Must be called within a barrier on `queue`
@@ -249,7 +289,7 @@ extension NostrTransport {
defer { scheduleNextReadAck() } defer { scheduleNextReadAck() }
guard let recipientNpub = resolveRecipientNpub(for: item.peerID), guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
let recipientHex = npubToHex(recipientNpub), let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return } let senderIdentity = try? dependencies.currentIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session) SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else { guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
@@ -260,7 +300,7 @@ extension NostrTransport {
} }
private func scheduleNextReadAck() { private func scheduleNextReadAck() {
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in dependencies.scheduleAfter(readAckInterval) { [weak self] in
self?.queue.async(flags: .barrier) { [weak self] in self?.queue.async(flags: .barrier) { [weak self] in
self?.isSendingReadAcks = false self?.isSendingReadAcks = false
self?.processReadQueueIfNeeded() self?.processReadQueueIfNeeded()
@@ -271,12 +311,12 @@ extension NostrTransport {
@MainActor @MainActor
private func resolveRecipientNpub(for peerID: PeerID) -> String? { private func resolveRecipientNpub(for peerID: PeerID) -> String? {
if let noiseKey = Data(hexString: peerID.id), if let noiseKey = Data(hexString: peerID.id),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey), let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
if peerID.id.count == 16, if peerID.id.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID), let fav = dependencies.favoriteStatusForPeerID(peerID),
let npub = fav.peerNostrPublicKey { let npub = fav.peerNostrPublicKey {
return npub return npub
} }
+91 -10
View File
@@ -6,6 +6,7 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
import UserNotifications import UserNotifications
#if os(iOS) #if os(iOS)
@@ -14,24 +15,103 @@ import UIKit
import AppKit import AppKit
#endif #endif
protocol NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
)
}
protocol NotificationRequestDelivering {
func add(_ request: UNNotificationRequest)
}
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
center.requestAuthorization(options: options, completionHandler: completionHandler)
}
}
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter) {
self.center = center
}
func add(_ request: UNNotificationRequest) {
Task {
try? await center.add(request)
}
}
}
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
func requestAuthorization(
options: UNAuthorizationOptions,
completionHandler: @escaping (Bool, Error?) -> Void
) {
completionHandler(false, nil)
}
}
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
func add(_ request: UNNotificationRequest) {}
}
final class NotificationService { final class NotificationService {
static let shared = NotificationService() static let shared = NotificationService()
private let isRunningTestsProvider: () -> Bool
private let authorizer: NotificationAuthorizing
private let requestDeliverer: NotificationRequestDelivering
/// Returns true if running in test environment (XCTest, Swift Testing, or CI) /// Returns true if running in test environment (XCTest, Swift Testing, or CI)
private var isRunningTests: Bool { private var isRunningTests: Bool {
let env = ProcessInfo.processInfo.environment isRunningTestsProvider()
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
} }
private init() {} private init() {
self.isRunningTestsProvider = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}
if isRunningTestsProvider() {
self.authorizer = NoopNotificationAuthorizer()
self.requestDeliverer = NoopNotificationRequestDeliverer()
} else {
let center = UNUserNotificationCenter.current()
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
}
}
internal init(
isRunningTestsProvider: @escaping () -> Bool,
authorizer: NotificationAuthorizing,
requestDeliverer: NotificationRequestDelivering
) {
self.isRunningTestsProvider = isRunningTestsProvider
self.authorizer = authorizer
self.requestDeliverer = requestDeliverer
}
func requestAuthorization() { func requestAuthorization() {
guard !isRunningTests else { return } guard !isRunningTests else { return }
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
if granted { if granted {
// Permission granted // Permission granted
} else { } else {
@@ -64,7 +144,7 @@ final class NotificationService {
trigger: nil // Deliver immediately trigger: nil // Deliver immediately
) )
UNUserNotificationCenter.current().add(request) requestDeliverer.add(request)
} }
func sendMentionNotification(from sender: String, message: String) { func sendMentionNotification(from sender: String, message: String) {
@@ -96,7 +176,8 @@ final class NotificationService {
func sendNetworkAvailableNotification(peerCount: Int) { func sendNetworkAvailableNotification(peerCount: Int) {
let title = "👥 bitchatters nearby!" let title = "👥 bitchatters nearby!"
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around" let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
let identifier = "network-available-\(Date().timeIntervalSince1970)" // Fixed identifier so iOS updates the existing notification instead of creating new ones
let identifier = "network-available"
sendLocalNotification( sendLocalNotification(
title: title, title: title,
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
+1
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Foundation import Foundation
import Combine import Combine
+1
View File
@@ -21,6 +21,7 @@ enum TransportConfig {
// Timers // Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let networkNotificationCooldownSeconds: TimeInterval = 300 // 5 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect // BLE duty/announce/connect
@@ -7,6 +7,7 @@
// //
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
+1
View File
@@ -1,5 +1,6 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
// Gossip-based sync manager using on-demand GCS filters // Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager { final class GossipSyncManager {
+17 -5
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import BitLogger import BitLogger
import BitFoundation
/// Manages outgoing sync requests and validates incoming responses. /// Manages outgoing sync requests and validates incoming responses.
/// ///
@@ -17,14 +18,21 @@ final class RequestSyncManager {
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent) private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
private var pendingRequests: [PeerID: TimeInterval] = [:] private var pendingRequests: [PeerID: TimeInterval] = [:]
private let responseWindow: TimeInterval
private let now: () -> TimeInterval
// Allow responses for 30s after request init(
private let responseWindow: TimeInterval = 30.0 responseWindow: TimeInterval = 30.0,
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
) {
self.responseWindow = responseWindow
self.now = now
}
/// Register that we are sending a sync request to a peer. /// Register that we are sending a sync request to a peer.
/// - Parameter peerID: The peer we are requesting sync from /// - Parameter peerID: The peer we are requesting sync from
func registerRequest(to peerID: PeerID) { func registerRequest(to peerID: PeerID) {
let now = Date().timeIntervalSince1970 let now = self.now()
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync) SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync)
self.pendingRequests[peerID] = now self.pendingRequests[peerID] = now
@@ -46,7 +54,7 @@ final class RequestSyncManager {
return false return false
} }
let now = Date().timeIntervalSince1970 let now = self.now()
if now - requestTime > responseWindow { if now - requestTime > responseWindow {
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security) SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
// We don't remove here because we might receive multiple packets for one request // We don't remove here because we might receive multiple packets for one request
@@ -59,7 +67,7 @@ final class RequestSyncManager {
/// Periodic cleanup of expired requests /// Periodic cleanup of expired requests
func cleanup() { func cleanup() {
let now = Date().timeIntervalSince1970 let now = self.now()
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
let originalCount = self.pendingRequests.count let originalCount = self.pendingRequests.count
self.pendingRequests = self.pendingRequests.filter { _, timestamp in self.pendingRequests = self.pendingRequests.filter { _, timestamp in
@@ -71,4 +79,8 @@ final class RequestSyncManager {
} }
} }
} }
var debugPendingRequestCount: Int {
queue.sync { pendingRequests.count }
}
} }
-22
View File
@@ -1,22 +0,0 @@
//
// Data+SHA256.swift
// bitchat
//
// Created by Islam on 26/09/2025.
//
import struct Foundation.Data
import struct CryptoKit.SHA256
extension Data {
/// Returns the hex representation of SHA256 hash
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
sha256Hash().hexEncodedString()
}
/// Returns the SHA256 hash wrapped in Data
func sha256Hash() -> Data {
Data(SHA256.hash(data: self))
}
}
+1 -3
View File
@@ -21,9 +21,7 @@ struct InputValidator {
/// Rejects strings containing control characters to prevent potential security issues /// Rejects strings containing control characters to prevent potential security issues
/// and UI rendering problems. This strict approach ensures data integrity at input time. /// and UI rendering problems. This strict approach ensures data integrity at input time.
static func validateUserString(_ string: String, maxLength: Int) -> String? { static func validateUserString(_ string: String, maxLength: Int) -> String? {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) guard let trimmed = string.trimmedOrNilIfEmpty, trimmed.count <= maxLength else { return nil }
guard !trimmed.isEmpty else { return nil }
guard trimmed.count <= maxLength else { return nil }
// Reject control characters outright instead of rewriting the string. // Reject control characters outright instead of rewriting the string.
// This prevents injection attacks and ensures consistent UI rendering. // This prevents injection attacks and ensures consistent UI rendering.
@@ -1,4 +1,5 @@
import Foundation import Foundation
import BitFoundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist. /// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver { struct PeerDisplayNameResolver {
+44
View File
@@ -0,0 +1,44 @@
//
// SystemSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
enum SystemSettings {
case bluetooth
case location
case microphone
#if os(macOS)
private static let baseURL = "x-apple.systempreferences:com.apple.preference.security"
private var macPrivacyAnchor: String {
switch self {
case .bluetooth: "Privacy_Bluetooth"
case .location: "Privacy_LocationServices"
case .microphone: "Privacy_Microphone"
}
}
#endif
func open() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#elseif os(macOS)
let urlString = "\(Self.baseURL)?\(macPrivacyAnchor)"
if let url = URL(string: urlString) {
NSWorkspace.shared.open(url)
}
#endif
}
}
+35 -32
View File
@@ -78,6 +78,7 @@
/// ///
import BitLogger import BitLogger
import BitFoundation
import Foundation import Foundation
import SwiftUI import SwiftUI
import Combine import Combine
@@ -144,10 +145,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects private let networkResetGraceSeconds: TimeInterval = TransportConfig.networkResetGraceSeconds // avoid refiring on short drops/reconnects
@Published var nickname: String = "" { @Published var nickname: String = "" {
didSet { didSet {
// Trim whitespace whenever nickname is set // Trim whitespace whenever nickname is set; whitespace-only becomes ""
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = nickname.trimmedOrNilIfEmpty ?? ""
if trimmed != nickname { if trimmed != nickname {
nickname = trimmed nickname = trimmed
return
} }
// Update mesh service nickname if it's initialized // Update mesh service nickname if it's initialized
if !meshService.myPeerID.isEmpty { if !meshService.myPeerID.isEmpty {
@@ -747,8 +749,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
private func loadNickname() { private func loadNickname() {
if let savedNickname = userDefaults.string(forKey: nicknameKey) { if let savedNickname = userDefaults.string(forKey: nicknameKey) {
// Trim whitespace when loading nickname = savedNickname.trimmed
nickname = savedNickname.trimmingCharacters(in: .whitespacesAndNewlines)
} else { } else {
nickname = "anon\(Int.random(in: 1000...9999))" nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname() saveNickname()
@@ -764,20 +765,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
func validateAndSaveNickname() { func validateAndSaveNickname() {
// Trim whitespace from nickname nickname = nickname.trimmedOrNilIfEmpty ?? "anon\(Int.random(in: 1000...9999))"
let trimmed = nickname.trimmingCharacters(in: .whitespacesAndNewlines)
// Check if nickname is empty after trimming
if trimmed.isEmpty {
nickname = "anon\(Int.random(in: 1000...9999))"
} else {
nickname = trimmed
}
saveNickname() saveNickname()
} }
// MARK: - Favorites Management
// MARK: - Blocked Users Management (Delegated to PeerStateManager) // MARK: - Blocked Users Management (Delegated to PeerStateManager)
@@ -998,8 +989,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
@MainActor @MainActor
func sendMessage(_ content: String) { func sendMessage(_ content: String) {
// Ignore messages that are empty or whitespace-only to prevent blank lines // Ignore messages that are empty or whitespace-only to prevent blank lines
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard let trimmed = content.trimmedOrNilIfEmpty else { return }
guard !trimmed.isEmpty else { return }
// Check for commands // Check for commands
if content.hasPrefix("/") { if content.hasPrefix("/") {
@@ -1811,6 +1801,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
} }
} }
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
if let peerID {
return getPrivateChatMessages(for: peerID)
} else {
return messages
}
}
@MainActor @MainActor
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] { func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = [] var combined: [BitchatMessage] = []
@@ -3002,7 +3000,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
Task { @MainActor in Task { @MainActor in
// Early validation // Early validation
guard !isMessageBlocked(message) else { return } guard !isMessageBlocked(message) else { return }
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.isPrivate else { return } guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
// Route to appropriate handler // Route to appropriate handler
if message.isPrivate { if message.isPrivate {
@@ -3175,7 +3173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
Task { @MainActor in Task { @MainActor in
let normalized = content.trimmingCharacters(in: .whitespacesAndNewlines) let normalized = content.trimmed
let publicMentions = parseMentions(from: normalized) let publicMentions = parseMentions(from: normalized)
let msg = BitchatMessage( let msg = BitchatMessage(
id: messageID, id: messageID,
@@ -3348,18 +3346,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.scheduleNetworkEmptyTimer() self.scheduleNetworkEmptyTimer()
} else { } else {
self.invalidateNetworkEmptyTimer() self.invalidateNetworkEmptyTimer()
// Trim out peers we no longer observe before comparing for new arrivals // Don't trim recentlySeenPeers here - let timers handle cleanup.
self.recentlySeenPeers.formIntersection(meshPeerSet) // Trimming immediately causes peers to be treated as "new" when they
// briefly drop and reconnect, triggering notification floods.
let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers) let newPeers = meshPeerSet.subtracting(self.recentlySeenPeers)
if !newPeers.isEmpty { if !newPeers.isEmpty {
self.lastNetworkNotificationTime = Date() // Rate limit: max one notification per 5 minutes
self.recentlySeenPeers.formUnion(newPeers) let cooldown = TransportConfig.networkNotificationCooldownSeconds
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count) if Date().timeIntervalSince(self.lastNetworkNotificationTime) >= cooldown {
SecureLogger.info( // Only mark peers as seen when we actually notify about them
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))", // This ensures peers arriving during cooldown will be included in the next notification
category: .session self.recentlySeenPeers.formUnion(newPeers)
) self.lastNetworkNotificationTime = Date()
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
)
}
self.scheduleNetworkResetTimer() self.scheduleNetworkResetTimer()
} }
} }
@@ -3776,10 +3781,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
// Removed background nudge notification for generic "new chats!" // Removed background nudge notification for generic "new chats!"
// Append via batching buffer (skip empty content) with simple dedup by ID // Append via batching buffer (skip empty content) with simple dedup by ID
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { if !finalMessage.content.trimmed.isEmpty, !messages.contains(where: { $0.id == finalMessage.id }) {
if !messages.contains(where: { $0.id == finalMessage.id }) { publicMessagePipeline.enqueue(finalMessage)
publicMessagePipeline.enqueue(finalMessage)
}
} }
} }
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
import Tor import Tor
@@ -56,6 +57,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!deduplicationService.hasProcessedNostrEvent(event.id) !deduplicationService.hasProcessedNostrEvent(event.id)
@@ -76,7 +78,7 @@ extension ChatViewModel {
} }
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) let nick = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick geoNicknames[event.pubkey.lowercased()] = nick
} }
@@ -114,7 +116,7 @@ extension ChatViewModel {
} }
let senderName = displayNameForNostrPubkey(event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) let content = event.content.trimmed
// Clamp future timestamps to now to avoid future-dated messages skewing order // Clamp future timestamps to now to avoid future-dated messages skewing order
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at)) let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
@@ -146,13 +148,12 @@ extension ChatViewModel {
} }
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return } guard !deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
deduplicationService.recordNostrEvent(giftWrap.id) deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id), guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id),
content.hasPrefix("bitchat1:"), let packet = Self.decodeEmbeddedBitChatPacket(from: content),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload) let noisePayload = NoisePayload.decode(packet.payload)
else { else {
@@ -192,7 +193,7 @@ extension ChatViewModel {
case .mesh: case .mesh:
refreshVisibleMessages(from: .mesh) refreshVisibleMessages(from: .mesh)
// Debug: log if any empty messages are present // Debug: log if any empty messages are present
let emptyMesh = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count let emptyMesh = messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 { if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
} }
@@ -254,6 +255,7 @@ extension ChatViewModel {
} }
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag // Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
@@ -307,8 +309,7 @@ extension ChatViewModel {
// Cache nickname from tag if present // Cache nickname from tag if present
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmingCharacters(in: .whitespacesAndNewlines) geoNicknames[event.pubkey.lowercased()] = nickTag[1].trimmed
geoNicknames[event.pubkey.lowercased()] = nick
} }
// Store mapping for geohash DM initiation // Store mapping for geohash DM initiation
@@ -324,8 +325,10 @@ extension ChatViewModel {
let content = event.content let content = event.content
// If this is a teleport presence event (no content), don't add to timeline // If this is a teleport presence event (no content), don't add to timeline
if let teleTag = event.tags.first(where: { $0.first == "t" }), teleTag.count >= 2, (teleTag[1] == "teleport"), if let teleTag = event.tags.first(where: { $0.first == "t" }),
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return return
} }
@@ -367,6 +370,7 @@ extension ChatViewModel {
} }
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if deduplicationService.hasProcessedNostrEvent(giftWrap.id) { if deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return return
} }
@@ -380,9 +384,7 @@ extension ChatViewModel {
SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session) SecureLogger.debug("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session)
guard content.hasPrefix("bitchat1:"), guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))),
let packet = BitchatPacket.from(packetData),
packet.type == MessageType.noiseEncrypted.rawValue, packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) let payload = NoisePayload.decode(packet.payload)
else { else {
@@ -485,6 +487,7 @@ extension ChatViewModel {
} }
func subscribeNostrEvent(_ event: NostrEvent, gh: String) { func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue || guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return } event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
@@ -495,8 +498,7 @@ extension ChatViewModel {
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
// Notify only on rising-edge: previously zero people, now someone sends a chat // Notify only on rising-edge: previously zero people, now someone sends a chat
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) guard let content = event.content.trimmedOrNilIfEmpty else { return }
guard !content.isEmpty else { return }
// Respect geohash blocks // Respect geohash blocks
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
@@ -602,6 +604,7 @@ extension ChatViewModel {
} }
func processNostrMessage(_ giftWrap: NostrEvent) async { func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return } guard let currentIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
do { do {
@@ -619,8 +622,7 @@ extension ChatViewModel {
// Check if it's a BitChat packet embedded in the content (bitchat1:...) // Check if it's a BitChat packet embedded in the content (bitchat1:...)
if content.hasPrefix("bitchat1:") { if content.hasPrefix("bitchat1:") {
guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), guard let packet = Self.decodeEmbeddedBitChatPacket(from: content) else {
let packet = BitchatPacket.from(packetData) else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session) SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return return
} }
@@ -631,24 +633,23 @@ extension ChatViewModel {
// Stable target ID if we know Noise key; otherwise temporary Nostr-based peer // Stable target ID if we know Noise key; otherwise temporary Nostr-based peer
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey) let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue { if packet.type == MessageType.noiseEncrypted.rawValue,
if let payload = NoisePayload.decode(packet.payload) { let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
// Store Nostr mapping // Store Nostr mapping
await MainActor.run { await MainActor.run {
nostrKeyMapping[targetPeerID] = senderPubkey nostrKeyMapping[targetPeerID] = senderPubkey
// Handle packet types // Handle packet types
switch payload.type { switch payload.type {
case .privateMessage: case .privateMessage:
handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp) handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: targetPeerID, id: currentIdentity, messageTimestamp: messageTimestamp)
case .delivered: case .delivered:
handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt: case .readReceipt:
handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID) handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse: case .verifyChallenge, .verifyResponse:
break break
}
} }
} }
} }
@@ -801,6 +802,19 @@ extension ChatViewModel {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite) messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} }
private static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
// Base64url length upper bound for maxBytes (padded length; unpadded is <= this).
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Self.base64URLDecode(encoded),
packetData.count <= maxBytes
else { return nil }
return BitchatPacket.from(packetData)
}
// MARK: - Geohash Nickname Resolution (for /block in geohash) // MARK: - Geohash Nickname Resolution (for /block in geohash)
func nostrPubkeyForDisplayName(_ name: String) -> String? { func nostrPubkeyForDisplayName(_ name: String) -> String? {
@@ -8,6 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import BitLogger import BitLogger
import BitFoundation
import SwiftUI import SwiftUI
extension ChatViewModel { extension ChatViewModel {
@@ -319,7 +320,7 @@ extension ChatViewModel {
} }
let targetPeer = selectedPrivateChatPeer let targetPeer = selectedPrivateChatPeer
let message = enqueueMediaMessage(content: "[voice] \(url.lastPathComponent)", targetPeer: targetPeer) let message = enqueueMediaMessage(content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id let messageID = message.id
let transferId = makeTransferID(messageID: messageID) let transferId = makeTransferID(messageID: messageID)
@@ -364,6 +365,36 @@ extension ChatViewModel {
} }
} }
#if os(iOS)
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#elseif os(macOS)
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached {
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
self.sendImage(from: processedURL)
}
} catch {
SecureLogger.error("Image processing failed: \(error)", category: .session)
}
}
}
#endif
@MainActor @MainActor
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) { func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard canSendMediaInCurrentContext else { guard canSendMediaInCurrentContext else {
@@ -398,7 +429,7 @@ extension ChatViewModel {
) )
guard packet.encode() != nil else { throw MediaSendError.encodingFailed } guard packet.encode() != nil else { throw MediaSendError.encodingFailed }
await MainActor.run { await MainActor.run {
let message = self.enqueueMediaMessage(content: "[image] \(outputURL.lastPathComponent)", targetPeer: targetPeer) let message = self.enqueueMediaMessage(content: "\(MimeType.Category.image.messagePrefix)\(outputURL.lastPathComponent)", targetPeer: targetPeer)
let messageID = message.id let messageID = message.id
let transferId = self.makeTransferID(messageID: messageID) let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID) self.registerTransfer(transferId: transferId, messageID: messageID)
@@ -517,20 +548,19 @@ extension ChatViewModel {
func cleanupLocalFile(forMessage message: BitchatMessage) { func cleanupLocalFile(forMessage message: BitchatMessage) {
// Check both outgoing and incoming directories for thorough cleanup // Check both outgoing and incoming directories for thorough cleanup
let prefixes = ["[voice] ", "[image] ", "[file] "] let categories: [MimeType.Category] = [.audio, .image, .file]
let subdirs = ["voicenotes/outgoing", "voicenotes/incoming", guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
"images/outgoing", "images/incoming", let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
"files/outgoing", "files/incoming"] let base = try? applicationFilesDirectory(),
// Security: Extract only the last path component to prevent directory traversal
guard let prefix = prefixes.first(where: { message.content.hasPrefix($0) }) else { return } let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
let rawFilename = String(message.content.dropFirst(prefix.count)).trimmingCharacters(in: .whitespacesAndNewlines) safeFilename != "." && safeFilename != ".."
guard !rawFilename.isEmpty, let base = try? applicationFilesDirectory() else { return } else {
return
// Security: Extract only the last path component to prevent directory traversal }
let safeFilename = (rawFilename as NSString).lastPathComponent
guard !safeFilename.isEmpty && safeFilename != "." && safeFilename != ".." else { return }
// Try all possible locations (outgoing and incoming) // Try all possible locations (outgoing and incoming)
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
for subdir in subdirs { for subdir in subdirs {
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename) let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
@@ -0,0 +1,142 @@
//
// VoiceRecordingViewModel.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
@MainActor
final class VoiceRecordingViewModel: ObservableObject {
enum State: Equatable {
case idle
case requestingPermission
case permissionDenied
case preparing
case recording(startDate: Date)
case error(message: String)
var isActive: Bool {
switch self {
case .preparing, .recording: true
case .idle, .requestingPermission, .permissionDenied, .error: false
}
}
var alertMessage: String {
switch self {
case .error(let message): message
case .permissionDenied: "Microphone access is required to record voice notes."
case .idle, .requestingPermission, .preparing, .recording: ""
}
}
fileprivate func duration(for date: Date) -> TimeInterval {
switch self {
case .idle, .requestingPermission, .preparing, .permissionDenied, .error: 0
case .recording(let startDate): date.timeIntervalSince(startDate)
}
}
}
var showAlert: Bool {
get {
switch state {
case .permissionDenied, .error: true
case .idle, .requestingPermission, .preparing, .recording: false
}
}
set {
if !newValue { state = .idle }
}
}
@Published private(set) var state = State.idle
func formattedDuration(for date: Date) -> String {
let clamped = max(0, state.duration(for: date))
let totalMilliseconds = Int(clamped * 1000)
let minutes = totalMilliseconds / 60_000
let seconds = (totalMilliseconds % 60_000) / 1_000
let centiseconds = (totalMilliseconds % 1_000) / 10
return String(format: "%02d:%02d.%02d", minutes, seconds, centiseconds)
}
func start(shouldShow: Bool) {
guard shouldShow, state == .idle else { return }
state = .requestingPermission
Task {
let granted = await VoiceRecorder.shared.requestPermission()
guard state == .requestingPermission else { return }
guard granted else {
state = .permissionDenied
return
}
state = .preparing
do {
try await VoiceRecorder.shared.startRecording()
guard state == .preparing else {
cancel()
return
}
state = .recording(startDate: Date())
} catch {
SecureLogger.error("Voice recording failed to start: \(error)", category: .session)
await VoiceRecorder.shared.cancelRecording()
guard state == .preparing else { return }
state = .error(message: "Could not start recording.")
}
}
}
func finish(completion: ((URL) -> Void)?) {
let previousState = state
switch previousState {
case .permissionDenied, .error:
return
case .idle, .requestingPermission, .preparing, .recording:
break
}
state = .idle
guard case .recording(let startDate) = previousState, let completion else {
Task { await VoiceRecorder.shared.cancelRecording() }
return
}
Task {
let finalDuration = Date().timeIntervalSince(startDate)
if let url = await VoiceRecorder.shared.stopRecording(),
isValidRecording(at: url, duration: finalDuration) {
completion(url)
} else {
guard state == .idle else { return }
state = .error(
message: finalDuration < VoiceRecorder.minRecordingDuration
? "Recording is too short."
: "Recording failed to save."
)
}
}
}
func cancel() {
finish(completion: nil)
}
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? NSNumber,
fileSize.intValue > 0,
duration >= VoiceRecorder.minRecordingDuration {
return true
}
try? FileManager.default.removeItem(at: url)
return false
}
}
@@ -13,7 +13,7 @@ struct TextMessageView: View {
@EnvironmentObject private var viewModel: ChatViewModel @EnvironmentObject private var viewModel: ChatViewModel
let message: BitchatMessage let message: BitchatMessage
@Binding var expandedMessageIDs: Set<String> @State private var expandedMessageIDs: Set<String> = []
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
@@ -66,14 +66,12 @@ struct TextMessageView: View {
} }
} }
@available(macOS 14, iOS 17, *)
#Preview { #Preview {
@Previewable @State var ids: Set<String> = []
let keychain = PreviewKeychainManager() let keychain = PreviewKeychainManager()
Group { Group {
List { List {
TextMessageView(message: .preview, expandedMessageIDs: $ids) TextMessageView(message: .preview)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(EmptyView()) .listRowBackground(EmptyView())
@@ -81,7 +79,7 @@ struct TextMessageView: View {
.environment(\.colorScheme, .light) .environment(\.colorScheme, .light)
List { List {
TextMessageView(message: .preview, expandedMessageIDs: $ids) TextMessageView(message: .preview)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
.listRowBackground(EmptyView()) .listRowBackground(EmptyView())
File diff suppressed because it is too large Load Diff
+1
View File
@@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import BitFoundation
struct FingerprintView: View { struct FingerprintView: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
@@ -0,0 +1,79 @@
//
// ImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(iOS)
import SwiftUI
/// Camera or Photo Library
struct ImagePickerView: UIViewControllerRepresentable {
let sourceType: UIImagePickerController.SourceType
let completion: (UIImage?) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = sourceType
picker.delegate = context.coordinator
picker.allowsEditing = false
// Use standard full screen - iOS handles safe areas automatically
picker.modalPresentationStyle = .fullScreen
// Force dark mode to make safe area bars black instead of white
picker.overrideUserInterfaceStyle = .dark
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: completion)
}
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let completion: (UIImage?) -> Void
init(completion: @escaping (UIImage?) -> Void) {
self.completion = completion
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
let image = info[.originalImage] as? UIImage
completion(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
completion(nil)
}
}
}
@available(iOS 17, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: UIImage?
VStack {
if let selectedImage {
Image(uiImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
ImagePickerView(sourceType: .photoLibrary) { image in
selectedImage = image
isPresented = false
}
}
}
#endif
@@ -0,0 +1,147 @@
//
// ImagePreviewView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(macOS)
import BitLogger
#endif
struct ImagePreviewView: View {
let url: URL
@Environment(\.dismiss) private var dismiss
#if os(iOS)
@State private var showExporter = false
@State private var platformImage: UIImage?
#else
@State private var platformImage: NSImage?
#endif
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VStack {
Spacer()
if let image = platformImage {
#if os(iOS)
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#else
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.padding()
#endif
} else {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
}
Spacer()
HStack {
Button(action: { dismiss() }) {
Text("close", comment: "Button to dismiss fullscreen media viewer")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).stroke(Color.white.opacity(0.5), lineWidth: 1))
}
Spacer()
Button(action: saveCopy) {
Text("save", comment: "Button to save media to device")
.font(.bitchatSystem(size: 15, weight: .semibold))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 12).fill(Color.blue.opacity(0.6)))
}
}
.padding([.horizontal, .bottom], 24)
}
}
.onAppear(perform: loadImage)
#if os(iOS)
.sheet(isPresented: $showExporter) {
FileExportWrapper(url: url)
}
#endif
}
private func loadImage() {
DispatchQueue.global(qos: .userInitiated).async {
#if os(iOS)
guard let image = UIImage(contentsOfFile: url.path) else { return }
#else
guard let image = NSImage(contentsOf: url) else { return }
#endif
DispatchQueue.main.async {
self.platformImage = image
}
}
}
private func saveCopy() {
#if os(iOS)
showExporter = true
#else
Task { @MainActor in
let panel = NSSavePanel()
panel.canCreateDirectories = true
panel.nameFieldStringValue = url.lastPathComponent
panel.prompt = "save"
if panel.runModal() == .OK, let destination = panel.url {
do {
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.copyItem(at: url, to: destination)
} catch {
SecureLogger.error("Failed to save image preview copy: \(error)", category: .session)
}
}
}
#endif
}
#if os(iOS)
private struct FileExportWrapper: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let controller = UIDocumentPickerViewController(forExporting: [url])
controller.shouldShowFileExtensions = true
return controller
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
}
#endif
}
#Preview {
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("dummy.jpg")
if !FileManager.default.fileExists(atPath: tempURL.path(percentEncoded: false)) {
#if os(iOS)
let image = UIImage(named: "dummy")
let data = image?.jpegData(compressionQuality: 0.8)
let _ = try? data?.write(to: tempURL)
#elseif os(macOS)
let image = NSImage(named: "dummy")
var rect = NSRect(origin: .zero, size: image?.size ?? .zero)
if let cgImage = image?.cgImage(forProposedRect: &rect, context: nil, hints: nil) {
let rep = NSBitmapImageRep(cgImage: cgImage)
let jpegData = rep.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
let _ = try? jpegData?.write(to: tempURL)
}
#endif
}
ImagePreviewView(url: tempURL)
}
@@ -0,0 +1,71 @@
//
// MacImagePickerView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
#if os(macOS)
import SwiftUI
struct MacImagePickerView: View {
let completion: (URL?) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(spacing: 16) {
Text("Choose an image")
.font(.headline)
Button("Select Image") {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.image, .png, .jpeg, .heic]
panel.message = "Choose an image to send"
if panel.runModal() == .OK {
completion(panel.url)
} else {
dismiss()
}
}
.buttonStyle(.borderedProminent)
Button("Cancel") {
completion(nil)
}
.buttonStyle(.bordered)
}
.padding(40)
.frame(minWidth: 300, minHeight: 150)
}
}
@available(OSX 14, *)
#Preview {
@Previewable @State var isPresented = true
@Previewable @State var selectedImage: NSImage?
VStack {
if let selectedImage {
Image(nsImage: selectedImage)
.resizable()
.scaledToFit()
} else {
Text("No image selected")
}
Button("Show") { isPresented = true }
}
.sheet(isPresented: $isPresented) {
MacImagePickerView { url in
selectedImage = url.map(NSImage.init)
isPresented = false
}
}
}
#endif
+4 -21
View File
@@ -125,7 +125,7 @@ struct LocationChannelsSheet: View {
Text(Strings.permissionDenied) Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button(Strings.openSettings) { openSystemLocationSettings() } Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain) .buttonStyle(.plain)
} }
case LocationChannelManager.PermissionState.authorized: case LocationChannelManager.PermissionState.authorized:
@@ -246,9 +246,7 @@ struct LocationChannelsSheet: View {
sectionDivider sectionDivider
torToggleSection torToggleSection
.padding(.top, 12) .padding(.top, 12)
Button(action: { Button(action: SystemSettings.location.open) {
openSystemLocationSettings()
}) {
Text(Strings.removeAccess) Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced)) .font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
@@ -304,7 +302,7 @@ struct LocationChannelsSheet: View {
} }
} }
let normalized = customGeohash let normalized = customGeohash
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmed
.lowercased() .lowercased()
.replacingOccurrences(of: "#", with: "") .replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized) let isValid = validateGeohash(normalized)
@@ -451,7 +449,7 @@ struct LocationChannelsSheet: View {
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]" // Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) { private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
guard let idx = s.lastIndex(of: "[") else { return (s, nil) } guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces) let prefix = String(s[..<idx]).trimmed
let suffix = String(s[idx...]) let suffix = String(s[idx...])
return (prefix, suffix) return (prefix, suffix)
} }
@@ -622,18 +620,3 @@ extension LocationChannelsSheet {
} }
} }
} }
// MARK: - Open Settings helper
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
+8 -5
View File
@@ -12,11 +12,15 @@ struct LocationNotesView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var draft: String = "" @State private var draft: String = ""
init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) { init(
geohash: String,
onNotesCountChanged: ((Int) -> Void)? = nil,
manager: LocationNotesManager? = nil
) {
let gh = geohash.lowercased() let gh = geohash.lowercased()
self.geohash = gh self.geohash = gh
self.onNotesCountChanged = onNotesCountChanged self.onNotesCountChanged = onNotesCountChanged
_manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) _manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
} }
private var backgroundColor: Color { colorScheme == .dark ? .black : .white } private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
@@ -260,14 +264,13 @@ struct LocationNotesView: View {
} }
private func send() { private func send() {
let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard let content = draft.trimmedOrNilIfEmpty else { return }
guard !content.isEmpty else { return }
manager.send(content: content, nickname: viewModel.nickname) manager.send(content: content, nickname: viewModel.nickname)
draft = "" draft = ""
} }
private var sendButtonEnabled: Bool { private var sendButtonEnabled: Bool {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays !draft.trimmed.isEmpty && manager.state != .noRelays
} }
// MARK: - Timestamp Formatting // MARK: - Timestamp Formatting
@@ -0,0 +1,87 @@
//
// MediaMessageView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import SwiftUI
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var viewModel: ChatViewModel
let message: BitchatMessage
let media: BitchatMessage.Media
@Binding var imagePreviewURL: URL?
var body: some View {
let state = mediaSendState(for: message)
let isFromMe = message.sender == viewModel.nickname || message.senderPeerID == viewModel.meshService.myPeerID
let cancelAction: (() -> Void)? = state.canCancel ? { viewModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(viewModel.formatMessageHeader(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
Group {
switch media {
case .voice(let url):
VoiceNoteView(
url: url,
isSending: state.isSending,
sendProgress: state.progress,
onCancel: cancelAction
)
case .image(let url):
BlockRevealImageView(
url: url,
revealProgress: state.progress,
isSending: state.isSending,
onCancel: cancelAction,
initiallyBlurred: !isFromMe,
onOpen: {
if !state.isSending {
imagePreviewURL = url
}
},
onDelete: !isFromMe ? { viewModel.deleteMediaMessage(messageID: message.id) } : nil
)
.frame(maxWidth: 280)
}
}
}
.padding(.vertical, 4)
}
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
var isSending = false
var progress: Double?
if let status = message.deliveryStatus {
switch status {
case .sending:
isSending = true
progress = 0
case .partiallyDelivered(let reached, let total):
if total > 0 {
isSending = true
progress = Double(reached) / Double(total)
}
case .sent, .read, .delivered, .failed:
break
}
}
let canCancel = isSending && message.sender == viewModel.nickname
let clamped = progress.map { max(0, min(1, $0)) }
return (isSending, isSending ? clamped : nil, canCancel)
}
}
+3 -17
View File
@@ -34,24 +34,10 @@ struct VoiceNoteView: View {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2) colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
} }
private var durationText: String {
let duration = playback.duration
guard duration.isFinite, duration > 0 else { return "--:--" }
let minutes = Int(duration) / 60
let seconds = Int(duration) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var currentText: String {
let current = playback.currentTime
guard current.isFinite, current > 0 else { return "00:00" }
let minutes = Int(current) / 60
let seconds = Int(current) % 60
return String(format: "%02d:%02d", minutes, seconds)
}
private var playbackLabel: String { private var playbackLabel: String {
playback.isPlaying ? currentText + "/" + durationText : durationText guard playback.duration.isFinite else { return "--:--" }
let seconds = playback.isPlaying ? playback.remainingSeconds : playback.roundedDuration
return String(format: "%02d:%02d", seconds / 60, seconds % 60)
} }
var body: some View { var body: some View {
+1
View File
@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import BitFoundation
struct MeshPeerList: View { struct MeshPeerList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
+449
View File
@@ -0,0 +1,449 @@
//
// MessageListView.swift
// bitchat
//
// Created by Islam on 30/03/2026.
//
import BitFoundation
import SwiftUI
private struct MessageDisplayItem: Identifiable {
let id: String
let message: BitchatMessage
}
struct MessageListView: View {
@EnvironmentObject private var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@Environment(\.colorScheme) private var colorScheme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@Binding var messageText: String
@Binding var selectedMessageSender: String?
@Binding var selectedMessageSenderID: PeerID?
@Binding var imagePreviewURL: URL?
@Binding var windowCountPublic: Int
@Binding var windowCountPrivate: [PeerID: Int]
@Binding var showSidebar: Bool
var isTextFieldFocused: FocusState<Bool>.Binding
@State private var showMessageActions = false
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
}
return windowCountPublic
}()
let messages = viewModel.getMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
guard !message.content.trimmed.isEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
messageRow(for: message)
.onAppear {
if message.id == windowedMessages.last?.id {
isAtBottom = true
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
expandWindow(
ifNeededFor: message,
allMessages: messages,
privatePeer: privatePeer,
proxy: proxy
)
}
}
.onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom = false
}
}
.contentShape(Rectangle())
.onTapGesture {
if message.sender != "system" {
messageText = "@\(message.sender) "
isTextFieldFocused.wrappedValue = true
}
}
.contextMenu {
Button("content.message.copy") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
}
}
.padding(.horizontal, 12)
.padding(.vertical, 1)
}
}
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 2)
}
.onOpenURL(perform: handleOpenURL)
.onTapGesture(count: 3) {
viewModel.sendMessage("/clear")
}
.onAppear {
scrollToBottom(on: proxy)
}
.onChange(of: privatePeer) { _ in
scrollToBottom(on: proxy)
}
.onChange(of: viewModel.messages.count) { _ in
onMessagesChange(proxy: proxy)
}
.onChange(of: viewModel.privateChats) { _ in
onPrivateChatsChange(proxy: proxy)
}
.onChange(of: locationManager.selectedChannel) { newChannel in
onSelectedChannelChange(newChannel, proxy: proxy)
}
.confirmationDialog(
selectedMessageSender.map { "@\($0)" } ?? String(localized: "content.actions.title", comment: "Fallback title for the message action sheet"),
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("content.actions.mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused.wrappedValue = true
}
}
Button("content.actions.direct_message") {
if let peerID = selectedMessageSenderID {
if peerID.isGeoChat {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full)
}
} else {
viewModel.startPrivateChat(with: peerID)
}
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = true
}
}
}
Button("content.actions.hug") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/hug @\(sender)")
}
}
Button("content.actions.slap") {
if let sender = selectedMessageSender {
viewModel.sendMessage("/slap @\(sender)")
}
}
Button("content.actions.block", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID
if let peerID = selectedMessageSenderID, peerID.isGeoChat,
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
}
Button("common.cancel", role: .cancel) {}
}
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID)
}
}
}
.onDisappear {
scrollThrottleTimer?.invalidate()
}
}
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
}
}
private extension MessageListView {
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {
if message.sender == "system" {
systemMessageRow(message)
} else if let media = message.mediaAttachment(for: viewModel.nickname) {
MediaMessageView(message: message, media: media, imagePreviewURL: $imagePreviewURL)
} else {
TextMessageView(message: message)
}
}
}
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: PeerID?,
proxy: ScrollViewProxy) {
let step = TransportConfig.uiWindowStepCount
let contextKey: String = {
if let peer = privatePeer {
"dm:\(peer)"
} else {
locationManager.selectedChannel.contextKey
}
}()
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPrivate[peer] = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
} else {
let current = windowCountPublic
let newCount = min(allMessages.count, current + step)
guard newCount != current else { return }
windowCountPublic = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
}
func handleOpenURL(_ url: URL) {
guard url.scheme == "bitchat" else { return }
switch url.host {
case "user":
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = PeerID(str: id.removingPercentEncoding ?? id)
selectedMessageSenderID = peerID
if peerID.isGeoDM || peerID.isGeoChat {
selectedMessageSender = viewModel.geohashDisplayName(for: peerID)
} else if let name = viewModel.meshService.peerNickname(peerID: peerID) {
selectedMessageSender = name
} else {
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
if viewModel.isSelfSender(peerID: peerID, displayName: selectedMessageSender) {
selectedMessageSender = nil
selectedMessageSenderID = nil
} else {
showMessageActions = true
}
case "geohash":
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(channel))
default:
return
}
}
func scrollToBottom(on proxy: ScrollViewProxy) {
isAtBottom = true
if let targetPeerID {
proxy.scrollTo(targetPeerID, anchor: .bottom)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
if let secondTarget = self.targetPeerID {
proxy.scrollTo(secondTarget, anchor: .bottom)
}
}
}
var targetPeerID: String? {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
if let last = viewModel.messages.suffix(300).last?.id {
return "\(locationManager.selectedChannel.contextKey)|\(last)"
}
return nil
}
func onMessagesChange(proxy: ScrollViewProxy) {
guard privatePeer == nil, let lastMsg = viewModel.messages.last else { return }
// If the newest message is from me, always scroll to bottom
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else { // Ensure we consider ourselves at bottom for subsequent messages
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = locationManager.selectedChannel.contextKey
if let target = viewModel.messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
// Throttle scroll animations to prevent excessive UI updates
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
// Immediate scroll if enough time has passed
scrollIfNeeded(date: now)
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onPrivateChatsChange(proxy: ScrollViewProxy) {
guard let peerID = privatePeer, let messages = viewModel.privateChats[peerID], let lastMsg = messages.last else {
return
}
// If the newest private message is from me, always scroll
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf && !isAtBottom { // Only autoscroll when user is at/near bottom
return
} else {
isAtBottom = true
}
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
proxy.scrollTo(target, anchor: .bottom)
}
}
// Same throttling for private chats
let now = Date()
if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
scrollIfNeeded(date: now)
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
Task { @MainActor in
scrollIfNeeded(date: Date())
}
}
}
}
func onSelectedChannelChange(_ channel: ChannelID, proxy: ScrollViewProxy) {
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
switch channel {
case .mesh:
break
case .location(let ch):
// Reset window size
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = viewModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
}
}
private extension ChannelID {
var contextKey: String {
switch self {
case .mesh: "mesh"
case .location(let ch): "geo:\(ch.geohash)"
}
}
}
//#Preview {
// MessageListView()
//}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "dummy.jpg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.chat.bitchat</string> <string>$(APP_GROUP_ID)</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
+1 -1
View File
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.chat.bitchat</string> <string>$(APP_GROUP_ID)</string>
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
+2
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupID</key>
<string>$(APP_GROUP_ID)</string>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@@ -13,7 +13,7 @@ import UniformTypeIdentifiers
/// Avoids deprecated Social framework and SLComposeServiceViewController. /// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController { final class ShareViewController: UIViewController {
// Bundle.main.bundleIdentifier would get the extension's bundleID // Bundle.main.bundleIdentifier would get the extension's bundleID
private static let groupID = "group.chat.bitchat" private static let groupID = Bundle.main.object(forInfoDictionaryKey: "AppGroupID") as? String ?? "group.chat.bitchat"
private enum Strings { private enum Strings {
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content") static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
@@ -6,7 +6,7 @@
<true/> <true/>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.chat.bitchat</string> <string>$(APP_GROUP_ID)</string>
</array> </array>
</dict> </dict>
</plist> </plist>
+52 -4
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceCoreTests { struct BLEServiceCoreTests {
@@ -23,10 +24,18 @@ struct BLEServiceCoreTests {
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp) let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
ble._test_handlePacket(packet, fromPeerID: sender) ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender) let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(receivedFirst)
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 }, ble._test_handlePacket(packet, fromPeerID: sender)
timeout: TestConstants.shortTimeout) let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
)
#expect(!receivedDuplicate)
let messages = delegate.publicMessagesSnapshot() let messages = delegate.publicMessagesSnapshot()
#expect(messages.count == 1) #expect(messages.count == 1)
@@ -49,13 +58,52 @@ struct BLEServiceCoreTests {
#expect(!didReceive) #expect(!didReceive)
#expect(delegate.publicMessagesSnapshot().isEmpty) #expect(delegate.publicMessagesSnapshot().isEmpty)
} }
@Test
func announceSenderMismatch_isRejected() async throws {
let ble = makeService()
let signer = NoiseEncryptionService(keychain: MockKeychain())
let announcement = AnnouncementPacket(
nickname: "Spoof",
noisePublicKey: signer.getStaticPublicKeyData(),
signingPublicKey: signer.getSigningPublicKeyData(),
directNeighbors: nil
)
let payload = try #require(announcement.encode(), "Failed to encode announcement")
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
let wrongFirst = derivedPeerID.bare.first == "0" ? "1" : "0"
let wrongBare = String(wrongFirst) + String(derivedPeerID.bare.dropFirst())
let wrongPeerID = PeerID(str: wrongBare)
let packet = BitchatPacket(
type: MessageType.announce.rawValue,
senderID: Data(hexString: wrongPeerID.id) ?? Data(),
recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload,
signature: nil,
ttl: 7
)
let signed = try #require(signer.signPacket(packet), "Failed to sign announce packet")
ble._test_handlePacket(signed, fromPeerID: wrongPeerID, preseedPeer: false)
_ = await TestHelpers.waitUntil({ !ble.currentPeerSnapshots().isEmpty }, timeout: 0.3)
#expect(ble.currentPeerSnapshots().isEmpty)
}
} }
private func makeService() -> BLEService { private func makeService() -> BLEService {
let keychain = MockKeychain() let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain) let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager) return BLEService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false
)
} }
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket { private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
+1
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct BLEServiceTests { struct BLEServiceTests {
+84
View File
@@ -0,0 +1,84 @@
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite("BitchatPeer Tests")
struct BitchatPeerTests {
typealias FavoriteRelationship = FavoritesPersistenceService.FavoriteRelationship
@Test("Connection state prioritizes bluetooth, mesh, nostr, then offline")
func connectionStatePriorityIsCorrect() {
let peerID = PeerID(str: "0123456789abcdef")
let noiseKey = Data((0..<32).map(UInt8.init))
let mutual = makeRelationship(isFavorite: true, theyFavoritedUs: true)
let bluetooth = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: true, isReachable: true)
let mesh = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: true)
var nostr = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
nostr.favoriteStatus = mutual
let offline = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "A", isConnected: false, isReachable: false)
#expect(bluetooth.connectionState == .bluetoothConnected)
#expect(mesh.connectionState == .meshReachable)
#expect(nostr.connectionState == .nostrAvailable)
#expect(offline.connectionState == .offline)
}
@Test("Display name falls back to peer prefix and offline icon reflects inbound favorite")
func displayNameAndOfflineIconUseDerivedState() {
let peerID = PeerID(str: "fedcba9876543210")
let noiseKey = Data((32..<64).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: false, theyFavoritedUs: true)
#expect(peer.displayName == String(peerID.id.prefix(8)))
#expect(peer.statusIcon == "🌙")
}
@Test("Mutual offline peers show Nostr icon")
func mutualFavoriteOfflinePeerShowsNostrIcon() {
let peerID = PeerID(str: "0011223344556677")
let noiseKey = Data((64..<96).map(UInt8.init))
var peer = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "Peer", isConnected: false, isReachable: false)
peer.favoriteStatus = makeRelationship(isFavorite: true, theyFavoritedUs: true)
#expect(peer.statusIcon == "🌐")
#expect(peer.isFavorite)
#expect(peer.isMutualFavorite)
#expect(peer.theyFavoritedUs)
}
@Test("Equality is based only on peer ID")
func equalityUsesPeerIDOnly() {
let peerID = PeerID(str: "8899aabbccddeeff")
let first = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 1, count: 32),
nickname: "First",
isConnected: false,
isReachable: false
)
let second = BitchatPeer(
peerID: peerID,
noisePublicKey: Data(repeating: 2, count: 32),
nickname: "Second",
isConnected: true,
isReachable: true
)
#expect(first == second)
}
private func makeRelationship(isFavorite: Bool, theyFavoritedUs: Bool) -> FavoriteRelationship {
FavoriteRelationship(
peerNoisePublicKey: Data(repeating: 7, count: 32),
peerNostrPublicKey: "npub1example",
peerNickname: "Peer",
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 1),
lastUpdated: Date(timeIntervalSince1970: 2)
)
}
}
@@ -7,6 +7,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
+751 -17
View File
@@ -8,6 +8,12 @@
import Testing import Testing
import Foundation import Foundation
import Combine import Combine
#if os(iOS)
import UIKit
#else
import AppKit
#endif
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -265,7 +271,7 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async { func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async throws {
let geohash = "u4pruydq" let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash)) let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
@@ -278,17 +284,16 @@ struct ChatViewModelNostrExtensionTests {
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel }) _ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
var event = NostrEvent( let signer = try NostrIdentity.generate()
pubkey: "pub1", let event = NostrEvent(
pubkey: signer.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Hello Geo" content: "Hello Geo"
) )
event.id = "evt1" let signed = try event.sign(with: signer.schnorrSigningKey())
event.sig = "sig" viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
let didAppend = await TestHelpers.waitUntil({ let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -305,16 +310,15 @@ struct ChatViewModelNostrExtensionTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash) let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
var event = NostrEvent( let event = NostrEvent(
pubkey: identity.publicKeyHex, pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Self echo" content: "Self echo"
) )
event.id = "evt-self" let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -323,24 +327,24 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func handleNostrEvent_skipsBlockedSender() async { func handleNostrEvent_skipsBlockedSender() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001" let blockedIdentity = try NostrIdentity.generate()
let blockedPubkey = blockedIdentity.publicKeyHex
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true) viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
var event = NostrEvent( let event = NostrEvent(
pubkey: blockedPubkey, pubkey: blockedPubkey,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Blocked" content: "Blocked"
) )
event.id = "evt-blocked" let signed = try event.sign(with: blockedIdentity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000) try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded() viewModel.publicMessagePipeline.flushIfNeeded()
@@ -348,6 +352,52 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" }) #expect(!viewModel.messages.contains { $0.content == "Blocked" })
} }
@Test @MainActor
func handleNostrEvent_rejectsInvalidSignature() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Valid"
)
var signed = try event.sign(with: identity.schnorrSigningKey())
signed.id = "deadbeef"
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
}
@Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty)
}
@Test @MainActor @Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async { func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
@@ -360,6 +410,274 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache")) #expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
} }
@Test @MainActor
func handleNostrEvent_presenceTracksParticipantWithoutTimelineMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let identity = try NostrIdentity.generate()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["g", geohash]],
content: ""
)
let signed = try event.sign(with: identity.schnorrSigningKey())
viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.geohashParticipantCount(for: geohash) >= 1)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(viewModel.messages.isEmpty)
}
@Test @MainActor
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "geo-ack-read"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .delivered(to: "Friend", at: Date())
)
]
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-private"
let convKey = PeerID(nostr_: sender.publicKeyHex)
let content = try privateMessageContent(
text: "Hello from gift wrap",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
timeout: 0.5
)
#expect(didStore)
#expect(viewModel.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-blocked"
let convKey = PeerID(nostr_: sender.publicKeyHex)
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
let content = try privateMessageContent(
text: "Blocked",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
try? await Task.sleep(nanoseconds: 50_000_000)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
@Test @MainActor
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered"
viewModel.privateChats[convKey] = [
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Friend",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
]
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
timeout: 0.5
)
#expect(didUpdate)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsNpub() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 80) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Alice"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: identity.publicKeyHex) == noiseKey)
}
@Test @MainActor
func findNoiseKey_matchesFavoriteStoredAsHex() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "ab", count: 32)
let noiseKey = Data((0..<32).map { UInt8(($0 + 112) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrHex,
peerNickname: "Bob"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
}
@Test @MainActor
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
let (viewModel, _) = makeTestableViewModel()
let identity = try NostrIdentity.generate()
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: identity.npub,
peerNickname: "Before"
)
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
viewModel.handleFavoriteNotification(
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
from: identity.publicKeyHex
)
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
#expect(relationship?.peerNickname == "Alice")
#expect(relationship?.peerNostrPublicKey == identity.npub)
#expect(relationship?.isFavorite == true)
}
@Test @MainActor
func geohashDMHelpers_exposeMappingAndDisplayName() async {
let (viewModel, _) = makeTestableViewModel()
let nostrHex = String(repeating: "cd", count: 32)
let convKey = PeerID(nostr_: nostrHex)
viewModel.geoNicknames[nostrHex] = "Alice"
viewModel.startGeohashDM(withPubkeyHex: nostrHex)
#expect(viewModel.selectedPrivateChatPeer == convKey)
#expect(viewModel.fullNostrHex(forSenderPeerID: convKey) == nostrHex)
#expect(viewModel.geohashDisplayName(for: convKey).hasPrefix("Alice"))
#expect(viewModel.nostrPubkeyForDisplayName("Alice") == nostrHex)
}
} }
// MARK: - Geohash Queue Tests // MARK: - Geohash Queue Tests
@@ -405,4 +723,420 @@ struct ChatViewModelGeoDMTests {
#expect(viewModel.privateChats[convKey]?.count == 1) #expect(viewModel.privateChats[convKey]?.count == 1)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID)) #expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
} }
@Test @MainActor
func sendGeohashDM_requiresActiveLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000001")
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.messages.count == 1)
#expect(viewModel.messages.last?.sender == "system")
}
@Test @MainActor
func sendGeohashDM_missingRecipientMapping_marksFailed() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let convKey = PeerID(nostr_: "0000000000000000000000000000000000000000000000000000000000000002")
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
}
@Test @MainActor
func sendGeohashDM_blockedRecipient_marksFailedAndAddsSystemMessage() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let recipientHex = "0000000000000000000000000000000000000000000000000000000000000003"
let convKey = PeerID(nostr_: recipientHex)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.nostrKeyMapping[convKey] = recipientHex
viewModel.identityManager.setNostrBlocked(recipientHex, isBlocked: true)
viewModel.sendGeohashDM("hello", to: convKey)
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func handlePrivateMessage_geohashViewingConversationRecordsReadReceipt() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000004"
let convKey = PeerID(nostr_: senderPubkey)
let messageID = "pm-viewing"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.selectedPrivateChatPeer = convKey
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: identity,
messageTimestamp: Date()
)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
#expect(viewModel.sentReadReceipts.contains(messageID))
#expect(!viewModel.unreadPrivateMessages.contains(convKey))
}
}
struct ChatViewModelMediaTransferTests {
@Test @MainActor
func handleTransferEvent_updatesPrivateMessageProgressAndClearsMappingOnCompletion() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10")
let message = viewModel.enqueueMediaMessage(content: "[voice] clip.m4a", targetPeer: peerID)
let transferID = "transfer-1"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.started(id: transferID, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 0, total: 4))
viewModel.handleTransferEvent(.updated(id: transferID, sentFragments: 2, totalFragments: 4))
#expect(isPartiallyDelivered(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id), reached: 2, total: 4))
viewModel.handleTransferEvent(.completed(id: transferID, totalFragments: 4))
#expect(isSent(status: deliveryStatus(in: viewModel, peerID: peerID, messageID: message.id)))
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(viewModel.transferIdToMessageIDs[transferID] == nil)
}
@Test @MainActor
func handleTransferEvent_cancelledRemovesOutgoingMessage() async {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "1111111111111111111111111111111111111111111111111111111111111111")
let message = viewModel.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: peerID)
let transferID = "transfer-2"
viewModel.registerTransfer(transferId: transferID, messageID: message.id)
viewModel.handleTransferEvent(.cancelled(id: transferID, sentFragments: 1, totalFragments: 3))
#expect(viewModel.privateChats[peerID]?.contains(where: { $0.id == message.id }) != true)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
}
@Test @MainActor
func sendVoiceNote_outsideAllowedContextDeletesTempFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice".utf8).write(to: url)
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendImage_outsideAllowedContextRunsCleanup() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
var cleanupCalled = false
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.sendImage(from: URL(fileURLWithPath: "/tmp/ignored.jpg")) {
cleanupCalled = true
}
#expect(cleanupCalled)
#expect(viewModel.messages.contains(where: { $0.sender == "system" }))
}
@Test @MainActor
func sendVoiceNote_privateChatUsesPrivateFileTransfer() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "2222222222222222222222222222222222222222222222222222222222222222")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
try Data("voice payload".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 0.5)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[voice]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
#expect(viewModel.transferIdToMessageIDs.count == 1)
}
@Test @MainActor
func sendVoiceNote_oversizedFileFailsAndDeletesTempFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "3333333333333333333333333333333333333333333333333333333333333333")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("voice-too-large-\(UUID().uuidString).m4a")
try Data(repeating: 0x55, count: FileTransferLimits.maxVoiceNoteBytes + 1).write(to: url, options: .atomic)
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendVoiceNote(at: url)
let didFail = await TestHelpers.waitUntil({
isFailed(status: viewModel.privateChats[peerID]?.last?.deliveryStatus)
}, timeout: 0.5)
#expect(didFail)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(transport.sentPrivateFiles.isEmpty)
}
@Test @MainActor
func sendImage_privateChatProcessesAndTransfersImage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "4444444444444444444444444444444444444444444444444444444444444444")
let sourceURL = try makeTemporaryImageURL()
defer { try? FileManager.default.removeItem(at: sourceURL) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: sourceURL)
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateFiles.count == 1 }, timeout: 1.0)
#expect(didSend)
#expect(transport.sentPrivateFiles.first?.peerID == peerID)
#expect(transport.sentPrivateFiles.first?.packet.mimeType == "image/jpeg")
#expect(viewModel.privateChats[peerID]?.last?.content.contains("[image]") == true)
#expect(viewModel.messageIDToTransferId.count == 1)
}
@Test @MainActor
func sendImage_invalidSourceAddsFailureSystemMessage() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "5555555555555555555555555555555555555555555555555555555555555555")
let url = FileManager.default.temporaryDirectory.appendingPathComponent("invalid-\(UUID().uuidString).jpg")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
viewModel.selectedPrivateChatPeer = peerID
viewModel.sendImage(from: url)
let didNotify = await TestHelpers.waitUntil({
viewModel.messages.contains(where: { $0.sender == "system" && $0.content.contains("Failed to prepare image") })
}, timeout: 2.0)
#expect(didNotify)
#expect(transport.sentPrivateFiles.isEmpty)
#expect(viewModel.privateChats[peerID]?.isEmpty != false)
}
@Test @MainActor
func clearTransferMapping_promotesQueuedTransferForSameID() async {
let (viewModel, _) = makeTestableViewModel()
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "first")
viewModel.registerTransfer(transferId: "transfer-queue", messageID: "second")
viewModel.clearTransferMapping(for: "first")
#expect(viewModel.messageIDToTransferId["first"] == nil)
#expect(viewModel.transferIdToMessageIDs["transfer-queue"] == ["second"])
#expect(viewModel.messageIDToTransferId["second"] == "transfer-queue")
}
@Test @MainActor
func cancelMediaSend_cancelsActiveTransferRemovesMessageAndDeletesFile() async throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "6666666666666666666666666666666666666666666666666666666666666666")
let fileName = "cancel-\(UUID().uuidString).m4a"
let fileURL = try mediaFileURL(subdirectory: "voicenotes/outgoing", fileName: fileName)
try Data("cancel me".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "cancel-msg",
sender: viewModel.nickname,
content: "[voice] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sending
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-cancel", messageID: message.id)
viewModel.cancelMediaSend(messageID: message.id)
#expect(transport.cancelledTransfers == ["transfer-cancel"])
#expect(viewModel.privateChats[peerID] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func deleteMediaMessage_removesStoredMessageAndCleansImageFile() async throws {
let (viewModel, _) = makeTestableViewModel()
let peerID = PeerID(str: "7777777777777777777777777777777777777777777777777777777777777777")
let fileName = "delete-\(UUID().uuidString).jpg"
let fileURL = try mediaFileURL(subdirectory: "images/outgoing", fileName: fileName)
try Data("image bytes".utf8).write(to: fileURL, options: .atomic)
let message = BitchatMessage(
id: "delete-msg",
sender: viewModel.nickname,
content: "[image] \(fileName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: viewModel.meshService.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
viewModel.registerTransfer(transferId: "transfer-delete", messageID: message.id)
viewModel.deleteMediaMessage(messageID: message.id)
#expect(viewModel.privateChats[peerID] == nil)
#expect(viewModel.messageIDToTransferId[message.id] == nil)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func makeTransferID_isPrefixedByMessageIDAndUnique() async {
let (viewModel, _) = makeTestableViewModel()
let first = viewModel.makeTransferID(messageID: "base")
let second = viewModel.makeTransferID(messageID: "base")
#expect(first.hasPrefix("base-"))
#expect(second.hasPrefix("base-"))
#expect(first != second)
}
}
private func base64URLEncode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
private func ackContent(type: NoisePayloadType, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: type,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidAckContent
}
private func privateMessageContent(text: String, messageID: String, senderPeerID: PeerID) throws -> String {
if let content = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: text,
messageID: messageID,
senderPeerID: senderPeerID
) {
return content
}
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
@MainActor
private func deliveryStatus(in viewModel: ChatViewModel, peerID: PeerID, messageID: String) -> DeliveryStatus? {
viewModel.privateChats[peerID]?.first(where: { $0.id == messageID })?.deliveryStatus
}
private func isFailed(status: DeliveryStatus?) -> Bool {
if case .failed = status {
return true
}
return false
}
private func isDelivered(status: DeliveryStatus?) -> Bool {
if case .delivered = status {
return true
}
return false
}
private func isRead(status: DeliveryStatus?) -> Bool {
if case .read = status {
return true
}
return false
}
private func isSent(status: DeliveryStatus?) -> Bool {
if case .sent = status {
return true
}
return false
}
private func isPartiallyDelivered(status: DeliveryStatus?, reached: Int, total: Int) -> Bool {
if case .partiallyDelivered(let actualReached, let actualTotal) = status {
return actualReached == reached && actualTotal == total
}
return false
}
private enum ChatViewModelExtensionsTestError: Error {
case invalidAckContent
case invalidPrivateMessageContent
}
private func mediaFileURL(subdirectory: String, fileName: String) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
).appendingPathComponent("files", isDirectory: true)
let directory = base.appendingPathComponent(subdirectory, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent(fileName)
}
private func makeTemporaryImageURL() throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("image-\(UUID().uuidString).png")
let data = try makeImageData()
try data.write(to: url, options: .atomic)
return url
}
private func makeImageData() throws -> Data {
#if os(iOS)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 64)).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(x: 0, y: 0, width: 64, height: 64))
}
guard let data = image.pngData() else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#else
let image = NSImage(size: CGSize(width: 64, height: 64))
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(x: 0, y: 0, width: 64, height: 64)).fill()
image.unlockFocus()
guard
let tiffData = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: tiffData),
let data = bitmap.representation(using: .png, properties: [:])
else {
throw ChatViewModelExtensionsTestError.invalidPrivateMessageContent
}
return data
#endif
} }
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
struct ChatViewModelRefactoringTests { struct ChatViewModelRefactoringTests {
@@ -125,26 +126,20 @@ struct ChatViewModelRefactoringTests {
let (viewModel, _, _) = makePinnedViewModel() let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_2") let senderID = PeerID(str: "sender_2")
// Setup // Action
let message = BitchatMessage( viewModel.didReceivePublicMessage(
id: "msg_2", from: senderID,
sender: "charlie", nickname: "charlie",
content: "Public Hi", content: "Public Hi",
timestamp: Date(), timestamp: Date(),
isRelay: false, messageID: "msg_2"
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
) )
// Action
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout // Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil( let found = await TestHelpers.waitUntil(
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) }, {
viewModel.timelineStore.messages(for: .mesh).contains(where: { $0.content == "Public Hi" })
},
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
) )
+5 -3
View File
@@ -8,6 +8,7 @@
import Testing import Testing
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
// MARK: - Test Helpers // MARK: - Test Helpers
@@ -195,10 +196,11 @@ struct ChatViewModelReceivingTests {
messageID: "pub-001" messageID: "pub-001"
) )
// Give time for async Task and pipeline processing let found = await TestHelpers.waitUntil({
try? await Task.sleep(nanoseconds: 500_000_000) viewModel.timelineStore.messages(for: .mesh).contains { $0.content == "Public hello from Bob" }
}, timeout: TestConstants.defaultTimeout)
#expect(viewModel.messages.contains { $0.content == "Public hello from Bob" }) #expect(found)
} }
} }
+391 -1
View File
@@ -1,11 +1,14 @@
import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
@Suite(.serialized)
struct CommandProcessorTests { struct CommandProcessorTests {
private var identityManager = MockIdentityManager(MockKeychain())
@MainActor @MainActor
@Test func slapNotFoundGrammar() { @Test func slapNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system") let result = processor.process("/slap @system")
switch result { switch result {
@@ -18,6 +21,7 @@ struct CommandProcessorTests {
@MainActor @MainActor
@Test func hugNotFoundGrammar() { @Test func hugNotFoundGrammar() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system") let result = processor.process("/hug @system")
switch result { switch result {
@@ -30,6 +34,7 @@ struct CommandProcessorTests {
@MainActor @MainActor
@Test func slapUsageMessage() { @Test func slapUsageMessage() {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager) let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap") let result = processor.process("/slap")
switch result { switch result {
@@ -39,4 +44,389 @@ struct CommandProcessorTests {
Issue.record("Expected error result for usage message") Issue.record("Expected error result for usage message")
} }
} }
@MainActor
@Test func msgStartsPrivateChatAndSendsMessage() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["alice"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/msg @alice hello there")
}
switch result {
case .success(let message):
#expect(message == "started private chat with alice")
default:
Issue.record("Expected success result")
}
#expect(context.startedPrivateChats == [peerID])
#expect(context.sentPrivateMessages.count == 1)
#expect(context.sentPrivateMessages.first?.content == "hello there")
#expect(context.sentPrivateMessages.first?.peerID == peerID)
}
@MainActor
@Test func whoInMeshListsSortedPeerNicknames() async {
let identityManager = MockIdentityManager(MockKeychain())
let transport = MockTransport()
transport.peerNicknames = [
PeerID(str: "b"): "bob",
PeerID(str: "a"): "alice"
]
let processor = CommandProcessor(contextProvider: MockCommandContextProvider(), meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: alice, bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func whoInGeohashListsVisibleParticipantsExcludingSelf() async throws {
let bridge = NostrIdentityBridge(keychain: MockKeychain())
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(idBridge: bridge)
let geohash = "u4pruy"
let selfPubkey = try bridge.deriveIdentity(forGeohash: geohash).publicKeyHex.lowercased()
context.visibleGeoParticipants = [
CommandGeoParticipant(id: selfPubkey, displayName: "me"),
CommandGeoParticipant(id: String(repeating: "b", count: 64), displayName: "bob")
]
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let result = await withSelectedChannel(channel) {
processor.process("/who")
}
switch result {
case .success(let message):
#expect(message == "online: bob")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func clearInPrivateChatRemovesOnlySelectedConversation() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let activePeer = PeerID(str: "active")
let otherPeer = PeerID(str: "other")
context.selectedPrivateChatPeer = activePeer
context.privateChats = [
activePeer: [makeMessage(sender: "alice", content: "secret")],
otherPeer: [makeMessage(sender: "bob", content: "keep")]
]
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.privateChats[activePeer] == [])
#expect(context.privateChats[otherPeer]?.count == 1)
}
@MainActor
@Test func clearInPublicChatClearsTimeline() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let processor = CommandProcessor(contextProvider: context, meshService: nil, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/clear")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.clearCurrentPublicTimelineCallCount == 1)
}
@MainActor
@Test func hugInPrivateChatSendsPersonalizedMessageAndLocalEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
context.selectedPrivateChatPeer = peerID
context.nicknameToPeerID["bob"] = peerID
transport.peerNicknames[peerID] = "Bob"
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/hug @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "* 🫂 me hugs you *")
#expect(context.localPrivateSystemMessages.first?.content == "🫂 you hugged bob")
#expect(context.localPrivateSystemMessages.first?.peerID == peerID)
}
@MainActor
@Test func slapInPublicChatSendsPublicRawAndEcho() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider(nickname: "me")
let peerID = PeerID(str: "abcd1234abcd1234")
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/slap @bob")
}
switch result {
case .handled:
break
default:
Issue.record("Expected handled result")
}
#expect(context.sentPublicRawMessages == ["* 🐟 me slaps bob around a bit with a large trout *"])
#expect(context.publicSystemMessages == ["🐟 me slaps bob around a bit with a large trout"])
}
@MainActor
@Test func blockWithoutArgsListsMeshAndGeohashBlocks() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerNicknames[peerID] = "bob"
transport.peerFingerprints[peerID] = "fp-bob"
context.blockedUsers = ["fp-bob"]
context.visibleGeoParticipants = [
CommandGeoParticipant(id: String(repeating: "c", count: 64), displayName: "carol")
]
identityManager.setNostrBlocked(String(repeating: "c", count: 64), isBlocked: true)
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let result = await withSelectedChannel(.mesh) {
processor.process("/block")
}
switch result {
case .success(let message):
#expect(message == "blocked peers: bob | geohash blocks: carol")
default:
Issue.record("Expected success result")
}
}
@MainActor
@Test func blockAndUnblockMeshPeerUpdateIdentityState() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
let transport = MockTransport()
let peerID = PeerID(str: "abcd1234abcd1234")
transport.peerFingerprints[peerID] = "fp-bob"
context.nicknameToPeerID["bob"] = peerID
let processor = CommandProcessor(contextProvider: context, meshService: transport, identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block @bob")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked bob. you will no longer receive messages from them")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isBlocked(fingerprint: "fp-bob"))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock bob")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked bob")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isBlocked(fingerprint: "fp-bob"))
}
@MainActor
@Test func blockAndUnblockGeohashPeerUseNostrBlockList() async {
let identityManager = MockIdentityManager(MockKeychain())
let context = MockCommandContextProvider()
context.displayNameToNostrPubkey["carol"] = String(repeating: "d", count: 64)
let processor = CommandProcessor(contextProvider: context, meshService: MockTransport(), identityManager: identityManager)
let blockResult = await withSelectedChannel(.mesh) {
processor.process("/block carol")
}
switch blockResult {
case .success(let message):
#expect(message == "blocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
let unblockResult = await withSelectedChannel(.mesh) {
processor.process("/unblock @carol")
}
switch unblockResult {
case .success(let message):
#expect(message == "unblocked carol in geohash chats")
default:
Issue.record("Expected success result")
}
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
}
@MainActor
@Test func favoriteCommandIsRejectedOutsideMesh() async {
let identityManager = MockIdentityManager(MockKeychain())
let processor = CommandProcessor(
contextProvider: MockCommandContextProvider(),
meshService: MockTransport(),
identityManager: identityManager
)
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: "u4pruy"))
let result = await withSelectedChannel(channel) {
processor.process("/fav alice")
}
switch result {
case .error(let message):
#expect(message == "favorites are only for mesh peers in #mesh")
default:
Issue.record("Expected error result")
}
}
@MainActor
private func withSelectedChannel<T>(_ channel: ChannelID, perform work: @escaping () throws -> T) async rethrows -> T {
let originalChannel = LocationChannelManager.shared.selectedChannel
await setSelectedChannel(channel)
do {
let result = try work()
await setSelectedChannel(originalChannel)
return result
} catch {
await setSelectedChannel(originalChannel)
throw error
}
}
@MainActor
private func setSelectedChannel(_ channel: ChannelID) async {
LocationChannelManager.shared.select(channel)
for _ in 0..<40 {
if LocationChannelManager.shared.selectedChannel == channel {
return
}
await Task.yield()
try? await Task.sleep(nanoseconds: 5_000_000)
}
}
private func makeMessage(sender: String, content: String) -> BitchatMessage {
BitchatMessage(
sender: sender,
content: content,
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false
)
}
}
@MainActor
private final class MockCommandContextProvider: CommandContextProvider {
var nickname: String
var selectedPrivateChatPeer: PeerID?
var blockedUsers: Set<String> = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
let idBridge: NostrIdentityBridge
var nicknameToPeerID: [String: PeerID] = [:]
var visibleGeoParticipants: [CommandGeoParticipant] = []
var displayNameToNostrPubkey: [String: String] = [:]
private(set) var startedPrivateChats: [PeerID] = []
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
private(set) var clearCurrentPublicTimelineCallCount = 0
private(set) var sentPublicRawMessages: [String] = []
private(set) var localPrivateSystemMessages: [(content: String, peerID: PeerID)] = []
private(set) var publicSystemMessages: [String] = []
private(set) var toggledFavorites: [PeerID] = []
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
self.nickname = nickname
self.idBridge = idBridge
}
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
nicknameToPeerID[nickname]
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
visibleGeoParticipants
}
func nostrPubkeyForDisplayName(_ displayName: String) -> String? {
displayNameToNostrPubkey[displayName]
}
func startPrivateChat(with peerID: PeerID) {
startedPrivateChats.append(peerID)
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
func clearCurrentPublicTimeline() {
clearCurrentPublicTimelineCallCount += 1
}
func sendPublicRaw(_ content: String) {
sentPublicRawMessages.append(content)
}
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID) {
localPrivateSystemMessages.append((content, peerID))
}
func addPublicSystemMessage(_ content: String) {
publicSystemMessages.append(content)
}
func toggleFavorite(peerID: PeerID) {
toggledFavorites.append(peerID)
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
favoriteNotifications.append((peerID, isFavorite))
}
} }
@@ -9,6 +9,7 @@
import Testing import Testing
import CryptoKit import CryptoKit
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
@testable import bitchat @testable import bitchat
struct PrivateChatE2ETests { struct PrivateChatE2ETests {
@@ -8,6 +8,7 @@
import Testing import Testing
import struct Foundation.UUID import struct Foundation.UUID
import BitFoundation
@testable import bitchat @testable import bitchat
struct PublicChatE2ETests { struct PublicChatE2ETests {
@@ -0,0 +1,67 @@
import Testing
import Foundation
#if os(iOS)
import UIKit
#else
import AppKit
#endif
@testable import bitchat
private func makeTemporaryFileURL(_ name: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(name)
}
#if os(iOS)
private func makePlatformImage(size: CGSize) -> UIImage {
UIGraphicsImageRenderer(size: size).image { context in
UIColor.systemTeal.setFill()
context.fill(CGRect(origin: .zero, size: size))
}
}
#else
private func makePlatformImage(size: CGSize) -> NSImage {
let image = NSImage(size: size)
image.lockFocus()
NSColor.systemTeal.setFill()
NSBezierPath(rect: CGRect(origin: .zero, size: size)).fill()
image.unlockFocus()
return image
}
#endif
struct ImageUtilsTests {
@Test
func processImage_rejectsOversizedSourceFile() throws {
let url = makeTemporaryFileURL("image-too-large.bin")
try Data(repeating: 0xFF, count: 10 * 1024 * 1024 + 1).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_rejectsInvalidImageData() throws {
let url = makeTemporaryFileURL("image-invalid.bin")
try Data("not-an-image".utf8).write(to: url, options: .atomic)
defer { try? FileManager.default.removeItem(at: url) }
#expect(throws: ImageUtilsError.self) {
try ImageUtils.processImage(at: url)
}
}
@Test
func processImage_writesCompressedJpeg() throws {
let image = makePlatformImage(size: CGSize(width: 1024, height: 768))
let outputURL = try ImageUtils.processImage(image, maxDimension: 256)
defer { try? FileManager.default.removeItem(at: outputURL) }
let data = try Data(contentsOf: outputURL)
#expect(outputURL.pathExtension.lowercased() == "jpg")
#expect(data.starts(with: Data([0xFF, 0xD8])))
#expect(data.count > 0)
}
}
@@ -9,6 +9,7 @@
import Testing import Testing
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
struct FragmentationTests { struct FragmentationTests {
@@ -28,7 +29,8 @@ struct FragmentationTests {
let ble = BLEService( let ble = BLEService(
keychain: mockKeychain, keychain: mockKeychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: mockIdentityManager identityManager: mockIdentityManager,
initializeBluetoothManagers: false
) )
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -63,7 +65,8 @@ struct FragmentationTests {
let ble = BLEService( let ble = BLEService(
keychain: mockKeychain, keychain: mockKeychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: mockIdentityManager identityManager: mockIdentityManager,
initializeBluetoothManagers: false
) )
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -97,7 +100,8 @@ struct FragmentationTests {
let ble = BLEService( let ble = BLEService(
keychain: mockKeychain, keychain: mockKeychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: mockIdentityManager identityManager: mockIdentityManager,
initializeBluetoothManagers: false
) )
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -153,7 +157,8 @@ struct FragmentationTests {
let ble = BLEService( let ble = BLEService(
keychain: mockKeychain, keychain: mockKeychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: mockIdentityManager identityManager: mockIdentityManager,
initializeBluetoothManagers: false
) )
let capture = CaptureDelegate() let capture = CaptureDelegate()
ble.delegate = capture ble.delegate = capture
@@ -205,16 +210,18 @@ extension FragmentationTests {
private var expectedPublicMessageCount: Int = 0 private var expectedPublicMessageCount: Int = 0
private var expectedReceivedMessageCount: Int = 0 private var expectedReceivedMessageCount: Int = 0
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] { private func withLock<T>(_ body: () -> T) -> T {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
return _publicMessages return body()
}
var publicMessages: [(peerID: PeerID, nickname: String, content: String)] {
withLock { _publicMessages }
} }
var receivedMessages: [BitchatMessage] { var receivedMessages: [BitchatMessage] {
lock.lock() withLock { _receivedMessages }
defer { lock.unlock() }
return _receivedMessages
} }
func didReceiveMessage(_ message: BitchatMessage) { func didReceiveMessage(_ message: BitchatMessage) {
@@ -251,27 +258,32 @@ extension FragmentationTests {
/// Waits for the specified number of public messages to be received /// Waits for the specified number of public messages to be received
func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws { func waitForPublicMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
lock.lock() let isAlreadySatisfied = withLock { () -> Bool in
if _publicMessages.count >= count { if _publicMessages.count >= count {
lock.unlock() return true
}
expectedPublicMessageCount = count
return false
}
if isAlreadySatisfied {
return return
} }
expectedPublicMessageCount = count
lock.unlock()
try await withThrowingTaskGroup(of: Void.self) { group in try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { group.addTask {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
self.lock.lock() let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition // Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install // where message arrives between initial check and continuation install
if self._publicMessages.count >= count { if self._publicMessages.count >= count {
self.lock.unlock() return true
}
self.publicMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume() continuation.resume()
return
} }
self.publicMessageContinuation = continuation
self.lock.unlock()
} }
} }
group.addTask { group.addTask {
@@ -285,27 +297,32 @@ extension FragmentationTests {
/// Waits for the specified number of received messages /// Waits for the specified number of received messages
func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws { func waitForReceivedMessages(count: Int, timeout: Duration = .seconds(2)) async throws {
lock.lock() let isAlreadySatisfied = withLock { () -> Bool in
if _receivedMessages.count >= count { if _receivedMessages.count >= count {
lock.unlock() return true
}
expectedReceivedMessageCount = count
return false
}
if isAlreadySatisfied {
return return
} }
expectedReceivedMessageCount = count
lock.unlock()
try await withThrowingTaskGroup(of: Void.self) { group in try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { group.addTask {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
self.lock.lock() let shouldResumeImmediately = self.withLock {
// Recheck count after acquiring lock to avoid race condition // Recheck count after acquiring lock to avoid race condition
// where message arrives between initial check and continuation install // where message arrives between initial check and continuation install
if self._receivedMessages.count >= count { if self._receivedMessages.count >= count {
self.lock.unlock() return true
}
self.receivedMessageContinuation = continuation
return false
}
if shouldResumeImmediately {
continuation.resume() continuation.resume()
return
} }
self.receivedMessageContinuation = continuation
self.lock.unlock()
} }
} }
group.addTask { group.addTask {
+30 -30
View File
@@ -131,7 +131,7 @@ struct NostrFilterPresenceTests {
@MainActor @MainActor
struct ChatViewModelPresenceHandlingTests { struct ChatViewModelPresenceHandlingTests {
@Test func handleNostrEvent_presenceUpdatesParticipantTracker() async { @Test func handleNostrEvent_presenceUpdatesParticipantTracker() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
@@ -139,18 +139,18 @@ struct ChatViewModelPresenceHandlingTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a presence event (kind 20001) // Create a presence event (kind 20001)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "presence_evt_1" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// Handle the event // Handle the event
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
// Allow async processing // Allow async processing
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -160,7 +160,7 @@ struct ChatViewModelPresenceHandlingTests {
#expect(count >= 1) #expect(count >= 1)
} }
@Test func handleNostrEvent_presenceDoesNotAddToTimeline() async { @Test func handleNostrEvent_presenceDoesNotAddToTimeline() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
@@ -169,17 +169,17 @@ struct ChatViewModelPresenceHandlingTests {
let initialMessageCount = viewModel.messages.count let initialMessageCount = viewModel.messages.count
// Create a presence event (kind 20001) // Create a presence event (kind 20001)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "presence_evt_2" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -187,24 +187,24 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.messages.count == initialMessageCount) #expect(viewModel.messages.count == initialMessageCount)
} }
@Test func handleNostrEvent_chatMessageUpdatesParticipant() async { @Test func handleNostrEvent_chatMessageUpdatesParticipant() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create a chat event (kind 20000) - NOT presence // Create a chat event (kind 20000) - NOT presence
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .ephemeralEvent, kind: .ephemeralEvent,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "Hello world" content: "Hello world"
) )
event.id = "chat_evt_1" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
viewModel.handleNostrEvent(event) viewModel.handleNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -223,25 +223,25 @@ struct ChatViewModelPresenceHandlingTests {
#expect(chatKind == 20000) #expect(chatKind == 20000)
} }
@Test func subscribeNostrEvent_acceptsPresenceKind() async { @Test func subscribeNostrEvent_acceptsPresenceKind() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq" let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
// Create presence event // Create presence event
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "test1234test1234test1234test1234test1234test1234test1234test1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", geohash]], tags: [["g", geohash]],
content: "" content: ""
) )
event.id = "subscribe_presence_evt" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// subscribeNostrEvent should accept kind 20001 // subscribeNostrEvent should accept kind 20001
viewModel.subscribeNostrEvent(event) viewModel.subscribeNostrEvent(signed)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -250,7 +250,7 @@ struct ChatViewModelPresenceHandlingTests {
#expect(count >= 1) #expect(count >= 1)
} }
@Test func subscribeNostrEvent_presenceForNonActiveGeohash() async { @Test func subscribeNostrEvent_presenceForNonActiveGeohash() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let activeGeohash = "u4pruydq" let activeGeohash = "u4pruydq"
let otherGeohash = "87yw7" let otherGeohash = "87yw7"
@@ -258,18 +258,18 @@ struct ChatViewModelPresenceHandlingTests {
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash))) viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: activeGeohash)))
// Create presence event for a DIFFERENT geohash // Create presence event for a DIFFERENT geohash
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "other1234other1234other1234other1234other1234other1234other1234", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .geohashPresence, kind: .geohashPresence,
tags: [["g", otherGeohash]], tags: [["g", otherGeohash]],
content: "" content: ""
) )
event.id = "other_geohash_presence" let signed = try event.sign(with: identity.schnorrSigningKey())
event.sig = "sig"
// Use subscribeNostrEvent with geohash parameter // Use subscribeNostrEvent with geohash parameter
viewModel.subscribeNostrEvent(event, gh: otherGeohash) viewModel.subscribeNostrEvent(signed, gh: otherGeohash)
try? await Task.sleep(nanoseconds: 50_000_000) try? await Task.sleep(nanoseconds: 50_000_000)
@@ -1,5 +1,6 @@
import Foundation import Foundation
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
struct GossipSyncManagerTests { struct GossipSyncManagerTests {
@@ -9,6 +9,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import Testing import Testing
import BitFoundation
@testable import bitchat @testable import bitchat
struct IntegrationTests { struct IntegrationTests {
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import BitFoundation
@testable import bitchat @testable import bitchat
final class TestNetworkHelper { final class TestNetworkHelper {
+8
View File
@@ -44,4 +44,12 @@ struct LocationChannelsTests {
let id2 = try idBridge.deriveIdentity(forGeohash: gh) let id2 = try idBridge.deriveIdentity(forGeohash: gh)
#expect(id1.publicKeyHex == id2.publicKeyHex) #expect(id1.publicKeyHex == id2.publicKeyHex)
} }
@Test func geohashNeighborsNearPoleSkipOutOfBoundsCells() {
let nearPole = Geohash.encode(latitude: 89.9999, longitude: 0.0, precision: 8)
let neighbors = Geohash.neighbors(of: nearPole)
#expect(neighbors.isEmpty == false)
#expect(neighbors.count < 8)
}
} }
+141 -48
View File
@@ -4,51 +4,49 @@ import Foundation
@MainActor @MainActor
struct LocationNotesManagerTests { struct LocationNotesManagerTests {
// func testSubscribeWithoutRelaysSetsNoRelaysState() { @Test
// var subscribeCalled = false func subscribeWithoutRelays_setsNoRelaysState() {
// let deps = LocationNotesDependencies( var subscribeCalled = false
// relayLookup: { _, _ in [] }, let deps = LocationNotesDependencies(
// subscribe: { _, _, _, _, _ in relayLookup: { _, _ in [] },
// subscribeCalled = true subscribe: { _, _, _, _, _ in
// }, subscribeCalled = true
// unsubscribe: { _ in }, },
// sendEvent: { _, _ in }, unsubscribe: { _ in },
// deriveIdentity: { _ in fatalError("should not derive identity") }, sendEvent: { _, _ in },
// now: { Date() } deriveIdentity: { _ in try NostrIdentity.generate() },
// ) 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")
// }
@Test func subscribeUsesGeoRelaysAndAppendsNotes() { let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
#expect(subscribeCalled == false)
#expect(manager.state == .noRelays)
#expect(manager.initialLoadComplete)
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
}
@Test
func sendWithoutRelays_surfacesNoRelaysError() {
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: "u4pruydq", dependencies: deps)
manager.send(content: "hello", nickname: "tester")
#expect(sendCalled == false)
#expect(manager.state == .noRelays)
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
}
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
var relaysCaptured: [String] = [] var relaysCaptured: [String] = []
var storedHandler: ((NostrEvent) -> Void)? var storedHandler: ((NostrEvent) -> Void)?
var storedEOSE: (() -> Void)? var storedEOSE: (() -> Void)?
@@ -71,15 +69,16 @@ struct LocationNotesManagerTests {
#expect(relaysCaptured == ["wss://relay.one"]) #expect(relaysCaptured == ["wss://relay.one"])
#expect(manager.state == .loading) #expect(manager.state == .loading)
var event = NostrEvent( let identity = try NostrIdentity.generate()
pubkey: "pub", let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .textNote, kind: .textNote,
tags: [["g", "u4pruydq"]], tags: [["g", "u4pruydq"]],
content: "hi" content: "hi"
) )
event.id = "event1" let signed = try event.sign(with: identity.schnorrSigningKey())
storedHandler?(event) storedHandler?(signed)
storedEOSE?() storedEOSE?()
#expect(manager.state == .ready) #expect(manager.state == .ready)
@@ -87,6 +86,100 @@ struct LocationNotesManagerTests {
#expect(manager.notes.first?.content == "hi") #expect(manager.notes.first?.content == "hi")
} }
@Test
func setGeohash_invalidValueIsIgnored() {
var subscribeCount = 0
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, _ in
subscribeCount += 1
},
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
manager.setGeohash("not-valid")
#expect(manager.geohash == "u4pruydq")
#expect(subscribeCount == 1)
}
@Test
func refreshAndCancel_manageSubscriptions() {
var subscribeIDs: [String] = []
var unsubscribedIDs: [String] = []
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, id, _, _, _ in
subscribeIDs.append(id)
},
unsubscribe: { id in
unsubscribedIDs.append(id)
},
sendEvent: { _, _ in },
deriveIdentity: { _ in try NostrIdentity.generate() },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
manager.refresh()
manager.cancel()
#expect(subscribeIDs.count == 2)
#expect(unsubscribedIDs.count == 2)
#expect(manager.state == .idle)
#expect(manager.errorMessage == nil)
}
@Test
func send_successCreatesLocalEchoAndClearsError() throws {
var sentEvents: [NostrEvent] = []
let identity = try NostrIdentity.generate()
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { event, _ in
sentEvents.append(event)
},
deriveIdentity: { _ in identity },
now: { Date(timeIntervalSince1970: 123_456) }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
manager.send(content: " hello note ", nickname: "Builder")
#expect(sentEvents.count == 1)
#expect(manager.state == .ready)
#expect(manager.errorMessage == nil)
#expect(manager.notes.first?.content == "hello note")
#expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true)
}
@Test
func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {
let deps = LocationNotesDependencies(
relayLookup: { _, _ in ["wss://relay.one"] },
subscribe: { _, _, _, _, _ in },
unsubscribe: { _ in },
sendEvent: { _, _ in },
deriveIdentity: { _ in throw TestError.shouldNotDerive },
now: { Date() }
)
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
manager.send(content: "hello", nickname: "Builder")
#expect(manager.errorMessage?.isEmpty == false)
manager.clearError()
#expect(manager.errorMessage == nil)
}
private enum TestError: Error { private enum TestError: Error {
case shouldNotDerive case shouldNotDerive
} }
@@ -9,9 +9,112 @@
import Testing import Testing
import Foundation import Foundation
import SwiftUI import SwiftUI
import BitFoundation
@testable import bitchat @testable import bitchat
struct MessageFormattingEngineTests { struct MessageFormattingEngineTests {
// MARK: - Formatting Behavior Tests
@MainActor
@Test func formatMessage_regularMessageFormatsHeaderContentAndTimestamp() {
let senderPeerID = PeerID(str: "abcdef1234567890")
let context = MockMessageFormattingContext(
nickname: "carol",
peerURLs: [senderPeerID: URL(string: "https://example.com/peers/alice")!]
)
let message = BitchatMessage(
id: "message-1",
sender: "alice#a1b2",
content: "hello #mesh https://example.com",
timestamp: Date(timeIntervalSince1970: 1_700_000_000),
isRelay: false,
senderPeerID: senderPeerID
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice#a1b2> hello #mesh https://example.com [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: false, isSelf: false) != nil)
}
@MainActor
@Test func formatMessage_systemMessageUsesSystemLayout() {
let context = MockMessageFormattingContext(nickname: "carol")
let message = BitchatMessage(
id: "system-1",
sender: "system",
content: "connected",
timestamp: Date(timeIntervalSince1970: 1_700_000_123),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .dark)
#expect(String(formatted.characters) == "* connected * [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: true, isSelf: false) != nil)
}
@MainActor
@Test func formatMessage_longSelfMessageFallsBackToPlainContentPath() {
let context = MockMessageFormattingContext(
nickname: "me",
selfMessageIDs: ["self-1"]
)
let longContent = String(repeating: "a", count: 4_500)
let message = BitchatMessage(
id: "self-1",
sender: "me#cafe",
content: longContent,
timestamp: Date(timeIntervalSince1970: 1_700_000_456),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@me#cafe> \(longContent) [\(message.formattedTimestamp)]")
#expect(message.getCachedFormattedText(isDark: false, isSelf: true) != nil)
}
@MainActor
@Test func formatMessage_mentionsAreRenderedThroughMentionFormatter() {
let context = MockMessageFormattingContext(nickname: "carol")
let message = BitchatMessage(
id: "message-mention",
sender: "alice",
content: "hi @bob#a1b2",
timestamp: Date(timeIntervalSince1970: 1_700_000_789),
isRelay: false
)
let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light)
#expect(String(formatted.characters) == "<@alice> hi bob#a1b2 [\(message.formattedTimestamp)]")
}
@MainActor
@Test func formatHeader_formatsNormalAndSystemSenders() {
let context = MockMessageFormattingContext(nickname: "carol")
let normalMessage = BitchatMessage(
id: "header-1",
sender: "alice#a1b2",
content: "hello",
timestamp: Date(timeIntervalSince1970: 1_700_001_000),
isRelay: false
)
let systemMessage = BitchatMessage(
id: "header-2",
sender: "system",
content: "notice",
timestamp: Date(timeIntervalSince1970: 1_700_001_111),
isRelay: false
)
let normalHeader = MessageFormattingEngine.formatHeader(normalMessage, context: context, colorScheme: .light)
let systemHeader = MessageFormattingEngine.formatHeader(systemMessage, context: context, colorScheme: .dark)
#expect(String(normalHeader.characters) == "<@alice#a1b2> ")
#expect(String(systemHeader.characters) == "system")
}
// MARK: - Mention Extraction Tests // MARK: - Mention Extraction Tests
@@ -221,3 +324,32 @@ struct MessageFormattingEngineTests {
#expect(content.hasVeryLongToken(threshold: 50)) #expect(content.hasVeryLongToken(threshold: 50))
} }
} }
@MainActor
private final class MockMessageFormattingContext: MessageFormattingContext {
let nickname: String
private let selfMessageIDs: Set<String>
private let peerURLs: [PeerID: URL]
init(
nickname: String,
selfMessageIDs: Set<String> = [],
peerURLs: [PeerID: URL] = [:]
) {
self.nickname = nickname
self.selfMessageIDs = selfMessageIDs
self.peerURLs = peerURLs
}
func isSelfMessage(_ message: BitchatMessage) -> Bool {
selfMessageIDs.contains(message.id)
}
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
.red
}
func peerURL(for peerID: PeerID) -> URL? {
peerURLs[peerID]
}
}
+1
View File
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockBLEBus { final class MockBLEBus {
+8 -8
View File
@@ -8,6 +8,7 @@
import Foundation import Foundation
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
/// In-memory BLE test harness used by E2E/Integration tests. /// In-memory BLE test harness used by E2E/Integration tests.
@@ -109,6 +110,11 @@ final class MockBLEService: NSObject {
return getPeerNicknames() return getPeerNicknames()
} }
/// Keep local echo synchronous so Swift Testing confirmations observe it deterministically.
private func deliverLocalEcho(_ message: BitchatMessage) {
delegate?.didReceiveMessage(message)
}
func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) { func sendMessage(_ content: String, mentions: [String] = [], to recipientID: String? = nil, messageID: String? = nil, timestamp: Date? = nil) {
let message = BitchatMessage( let message = BitchatMessage(
id: messageID ?? UUID().uuidString, id: messageID ?? UUID().uuidString,
@@ -137,10 +143,7 @@ final class MockBLEService: NSObject {
sentMessages.append((message, packet)) sentMessages.append((message, packet))
sentPackets.append(packet) sentPackets.append(packet)
// Simulate local echo deliverLocalEcho(message)
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveMessage(message)
}
// Surface raw packet to tests that intercept/relay/encrypt // Surface raw packet to tests that intercept/relay/encrypt
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
@@ -190,10 +193,7 @@ final class MockBLEService: NSObject {
sentMessages.append((message, packet)) sentMessages.append((message, packet))
sentPackets.append(packet) sentPackets.append(packet)
// Simulate local echo deliverLocalEcho(message)
DispatchQueue.main.async { [weak self] in
self?.delegate?.didReceiveMessage(message)
}
// Surface raw packet to tests that intercept/relay/encrypt // Surface raw packet to tests that intercept/relay/encrypt
packetDeliveryHandler?(packet) packetDeliveryHandler?(packet)
@@ -7,6 +7,7 @@
// //
import Foundation import Foundation
import BitFoundation
@testable import bitchat @testable import bitchat
final class MockIdentityManager: SecureIdentityStateManagerProtocol { final class MockIdentityManager: SecureIdentityStateManagerProtocol {
+10 -3
View File
@@ -9,6 +9,7 @@
import Foundation import Foundation
import Combine import Combine
import CoreBluetooth import CoreBluetooth
import BitFoundation
@testable import bitchat @testable import bitchat
/// Mock Transport implementation for testing ChatViewModel in isolation. /// Mock Transport implementation for testing ChatViewModel in isolation.
@@ -35,6 +36,9 @@ final class MockTransport: Transport {
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = [] private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] private(set) var sentDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = [] private(set) var sentFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var sentBroadcastFiles: [(packet: BitchatFilePacket, transferID: String)] = []
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var startServicesCallCount = 0 private(set) var startServicesCallCount = 0
@@ -139,15 +143,15 @@ final class MockTransport: Transport {
} }
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) { func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
// Not tracked for current tests sentBroadcastFiles.append((packet, transferId))
} }
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) { func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
// Not tracked for current tests sentPrivateFiles.append((packet, peerID, transferId))
} }
func cancelTransfer(_ transferId: String) { func cancelTransfer(_ transferId: String) {
// Not tracked for current tests cancelledTransfers.append(transferId)
} }
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
@@ -167,6 +171,9 @@ final class MockTransport: Transport {
sentReadReceipts.removeAll() sentReadReceipts.removeAll()
sentDeliveryAcks.removeAll() sentDeliveryAcks.removeAll()
sentFavoriteNotifications.removeAll() sentFavoriteNotifications.removeAll()
sentBroadcastFiles.removeAll()
sentPrivateFiles.removeAll()
cancelledTransfers.removeAll()
sentVerifyChallenges.removeAll() sentVerifyChallenges.removeAll()
sentVerifyResponses.removeAll() sentVerifyResponses.removeAll()
startServicesCallCount = 0 startServicesCallCount = 0
+868
View File
@@ -0,0 +1,868 @@
import CryptoKit
import Foundation
import Testing
import BitFoundation
@testable import bitchat
@Suite("Noise Coverage Tests")
struct NoiseCoverageTests {
private let keychain = MockKeychain()
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
private let alicePeerID = PeerID(str: "0011223344556677")
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
private let charliePeerID = PeerID(str: "fedcba9876543210")
@Test("Protocol metadata and handshake patterns expose expected values")
func protocolMetadataAndHandshakePatterns() {
let ikName = NoiseProtocolName(pattern: NoisePattern.IK.patternName)
#expect(ikName.pattern == "IK")
#expect(ikName.dh == "25519")
#expect(ikName.cipher == "ChaChaPoly")
#expect(ikName.hash == "SHA256")
#expect(ikName.fullName == "Noise_IK_25519_ChaChaPoly_SHA256")
#expect(NoisePattern.XX.patternName == "XX")
#expect(NoisePattern.IK.patternName == "IK")
#expect(NoisePattern.NK.patternName == "NK")
let ikPatterns = NoisePattern.IK.messagePatterns
#expect(ikPatterns.count == 2)
#expect(ikPatterns[0] == [.e, .es, .s, .ss])
#expect(ikPatterns[1] == [.e, .ee, .se])
let nkPatterns = NoisePattern.NK.messagePatterns
#expect(nkPatterns.count == 2)
#expect(nkPatterns[0] == [.e, .es])
#expect(nkPatterns[1] == [.e, .ee])
}
@Test("Symmetric state supports long protocol names and mixKeyAndHash")
func symmetricStateLongNameAndMixKeyAndHash() {
let longName = String(repeating: "NoiseProtocol_", count: 3)
let symmetricState = NoiseSymmetricState(protocolName: longName)
let initialHash = symmetricState.getHandshakeHash()
#expect(initialHash.count == 32)
#expect(!symmetricState.hasCipherKey())
symmetricState.mixKeyAndHash(Data("input-key-material".utf8))
#expect(symmetricState.hasCipherKey())
#expect(symmetricState.getHandshakeHash() != initialHash)
}
@Test("Cipher state rejects duplicate and stale extracted nonces")
func cipherStateRejectsDuplicateAndStaleNonces() throws {
let key = SymmetricKey(size: .bits256)
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
let initialPayload = try makeExtractedNoncePayload(
key: key,
nonce: 0,
plaintext: Data("nonce-0".utf8)
)
let initialPlaintext = try receiver.decrypt(ciphertext: initialPayload)
#expect(initialPlaintext == Data("nonce-0".utf8))
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: initialPayload)
}
for nonce in 1...1024 {
let payload = try makeExtractedNoncePayload(
key: key,
nonce: UInt64(nonce),
plaintext: Data("nonce-\(nonce)".utf8)
)
let plaintext = try receiver.decrypt(ciphertext: payload)
#expect(plaintext == Data("nonce-\(nonce)".utf8))
}
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: initialPayload)
}
}
@Test("Cipher state handles large nonce jumps and associated-data mismatches")
func cipherStateHandlesLargeJumpsAndAADMismatch() throws {
let key = SymmetricKey(size: .bits256)
let extractedReceiver = NoiseCipherState(key: key, useExtractedNonce: true)
let jumped = try makeExtractedNoncePayload(
key: key,
nonce: 1500,
plaintext: Data("future".utf8)
)
let slightlyOlder = try makeExtractedNoncePayload(
key: key,
nonce: 1499,
plaintext: Data("older".utf8)
)
let tooOld = try makeExtractedNoncePayload(
key: key,
nonce: 100,
plaintext: Data("ancient".utf8)
)
#expect(try extractedReceiver.decrypt(ciphertext: jumped) == Data("future".utf8))
#expect(try extractedReceiver.decrypt(ciphertext: slightlyOlder) == Data("older".utf8))
#expect(throws: (any Error).self) {
try extractedReceiver.decrypt(ciphertext: tooOld)
}
let sender = NoiseCipherState(key: key)
let receiver = NoiseCipherState(key: key)
let plaintext = Data("associated-data".utf8)
let aad = Data("good-aad".utf8)
let ciphertext = try sender.encrypt(plaintext: plaintext, associatedData: aad)
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: ciphertext, associatedData: Data("bad-aad".utf8))
}
#expect(try receiver.decrypt(ciphertext: ciphertext, associatedData: aad) == plaintext)
#expect(throws: (any Error).self) {
try receiver.decrypt(ciphertext: Data(repeating: 0xAA, count: 15))
}
}
@Test("Cipher state covers nonce guard rails and extracted payload bounds")
func cipherStateCoversNonceGuardRailsAndExtractedPayloadBounds() throws {
let uninitializedCipher = NoiseCipherState()
#expect(throws: NoiseError.uninitializedCipher) {
try uninitializedCipher.encrypt(plaintext: Data("missing-key".utf8))
}
#expect(throws: NoiseError.uninitializedCipher) {
try uninitializedCipher.decrypt(ciphertext: Data(repeating: 0x00, count: 16))
}
#expect(try uninitializedCipher.extractNonceFromCiphertextPayloadForTesting(Data([0x00, 0x01, 0x02])) == nil)
let key = SymmetricKey(size: .bits256)
let highNonceCipher = NoiseCipherState(key: key)
highNonceCipher.setNonceForTesting(1_000_000_001)
#expect(throws: Never.self) {
_ = try highNonceCipher.encrypt(plaintext: Data("high-nonce".utf8))
}
let exhaustedCipher = NoiseCipherState(key: key)
exhaustedCipher.setNonceForTesting(UInt64(UInt32.max))
#expect(throws: NoiseError.nonceExceeded) {
try exhaustedCipher.encrypt(plaintext: Data("nonce-limit".utf8))
}
}
@Test("Handshake validation rejects malformed keys and messages")
func handshakeValidationRejectsMalformedInputs() throws {
let responder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: (any Error).self) {
try responder.readMessage(Data(repeating: 0x00, count: 31))
}
let invalidKeys = [
Data(),
Data(repeating: 0x00, count: 32),
Data([0x01] + Array(repeating: 0x00, count: 31)),
Data(repeating: 0xFF, count: 32),
]
for invalidKey in invalidKeys {
#expect(throws: (any Error).self) {
_ = try NoiseHandshakeState.validatePublicKey(invalidKey)
}
}
let valid = aliceStaticKey.publicKey.rawRepresentation
let roundTripped = try NoiseHandshakeState.validatePublicKey(valid)
#expect(roundTripped.rawRepresentation == valid)
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responderForTamper = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage()
_ = try responderForTamper.readMessage(message1)
var message2 = try responderForTamper.writeMessage()
message2[40] ^= 0x01
#expect(throws: (any Error).self) {
try initiator.readMessage(message2)
}
}
@Test("Handshake readers reject invalid ephemeral and truncated static payloads")
func handshakeReadersRejectInvalidEphemeralAndTruncatedStaticPayloads() throws {
let invalidEphemeralResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.invalidMessage) {
try invalidEphemeralResponder.readMessage(Data(repeating: 0x00, count: 32))
}
let truncatedStaticInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
_ = try truncatedStaticInitiator.writeMessage()
let responderEphemeralOnly = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
#expect(throws: NoiseError.invalidMessage) {
try truncatedStaticInitiator.readMessage(responderEphemeralOnly)
}
}
@Test("IK handshake completes and supports transport messages")
func ikHandshakeCompletesAndSupportsTransportMessages() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .IK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let outboundPayload = Data("ik-outbound".utf8)
let returnPayload = Data("ik-return".utf8)
let message1 = try initiator.writeMessage(payload: outboundPayload)
#expect(try responder.readMessage(message1) == outboundPayload)
let message2 = try responder.writeMessage(payload: returnPayload)
#expect(try initiator.readMessage(message2) == returnPayload)
#expect(initiator.isHandshakeComplete())
#expect(responder.isHandshakeComplete())
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
useExtractedNonce: true
)
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
useExtractedNonce: true
)
#expect(initiatorHash == responderHash)
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("ik-transport".utf8))
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("ik-transport".utf8))
let serverCiphertext = try responderSend.encrypt(plaintext: Data("ik-response".utf8))
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("ik-response".utf8))
}
@Test("NK handshake requires a responder static key and supports transport messages")
func nkHandshakeRequiresStaticAndSupportsTransportMessages() throws {
let missingStaticInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .NK,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: (any Error).self) {
try missingStaticInitiator.writeMessage()
}
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .NK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let outboundPayload = Data("nk-outbound".utf8)
let returnPayload = Data("nk-return".utf8)
let message1 = try initiator.writeMessage(payload: outboundPayload)
#expect(try responder.readMessage(message1) == outboundPayload)
let message2 = try responder.writeMessage(payload: returnPayload)
#expect(try initiator.readMessage(message2) == returnPayload)
#expect(initiator.isHandshakeComplete())
#expect(responder.isHandshakeComplete())
let (initiatorSend, initiatorReceive, initiatorHash) = try initiator.getTransportCiphers(
useExtractedNonce: true
)
let (responderSend, responderReceive, responderHash) = try responder.getTransportCiphers(
useExtractedNonce: true
)
#expect(initiatorHash == responderHash)
let clientCiphertext = try initiatorSend.encrypt(plaintext: Data("nk-transport".utf8))
#expect(try responderReceive.decrypt(ciphertext: clientCiphertext) == Data("nk-transport".utf8))
let serverCiphertext = try responderSend.encrypt(plaintext: Data("nk-response".utf8))
#expect(try initiatorReceive.decrypt(ciphertext: serverCiphertext) == Data("nk-response".utf8))
}
@Test("Responder-side NK writes require peer ephemeral input")
func responderWritesRequirePeerEphemeralInput() {
let nkResponder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try nkResponder.writeMessage()
}
}
@Test("Direct DH helpers reject missing keys across all patterns")
func directDHHelpersRejectMissingKeysAcrossAllPatterns() throws {
let eeState = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try eeState.performDHOperationForTesting(.ee)
}
let esInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try esInitiator.performDHOperationForTesting(.es)
}
let esResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try esResponder.performDHOperationForTesting(.es)
}
let seInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try seInitiator.performDHOperationForTesting(.se)
}
let seResponder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
#expect(throws: NoiseError.missingKeys) {
try seResponder.performDHOperationForTesting(.se)
}
let ssState = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
#expect(throws: NoiseError.missingKeys) {
try ssState.performDHOperationForTesting(.ss)
}
#expect(throws: Never.self) {
try eeState.performDHOperationForTesting(.e)
try eeState.performDHOperationForTesting(.s)
}
}
@Test("Prepared handshake writers cover remaining missing-key branches")
func preparedHandshakeWritersCoverRemainingMissingKeyBranches() {
let eeResponder = NoiseHandshakeState(
role: .responder,
pattern: .NK,
keychain: keychain,
localStaticKey: bobStaticKey
)
eeResponder.setCurrentPatternForTesting(1)
#expect(throws: NoiseError.missingKeys) {
try eeResponder.writeMessage()
}
let seInitiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
seInitiator.setCurrentPatternForTesting(2)
#expect(throws: NoiseError.missingKeys) {
try seInitiator.writeMessage()
}
let seResponder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
seResponder.setCurrentPatternForTesting(1)
seResponder.setRemoteEphemeralPublicKeyForTesting(Curve25519.KeyAgreement.PrivateKey().publicKey)
#expect(throws: NoiseError.missingKeys) {
try seResponder.writeMessage()
}
}
@Test("Completed handshakes reject additional reads and writes")
func completedHandshakesRejectAdditionalReadsAndWrites() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .IK,
keychain: keychain,
localStaticKey: aliceStaticKey,
remoteStaticKey: bobStaticKey.publicKey
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .IK,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage(payload: Data("first".utf8))
_ = try responder.readMessage(message1)
let message2 = try responder.writeMessage(payload: Data("second".utf8))
_ = try initiator.readMessage(message2)
#expect(throws: NoiseError.handshakeComplete) {
try initiator.writeMessage()
}
#expect(throws: NoiseError.handshakeComplete) {
try responder.readMessage(message1)
}
}
@Test("XX final message requires a local static key")
func xxFinalMessageRequiresLocalStaticKey() throws {
let initiator = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: nil
)
let responder = NoiseHandshakeState(
role: .responder,
pattern: .XX,
keychain: keychain,
localStaticKey: bobStaticKey
)
let message1 = try initiator.writeMessage()
_ = try responder.readMessage(message1)
let message2 = try responder.writeMessage()
_ = try initiator.readMessage(message2)
#expect(throws: (any Error).self) {
try initiator.writeMessage()
}
}
@Test("Responder start handshake is empty and transport ciphers require completion")
func responderStartHandshakeAndIncompleteTransportCiphers() throws {
let responderSession = NoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
let incompleteHandshake = NoiseHandshakeState(
role: .initiator,
pattern: .XX,
keychain: keychain,
localStaticKey: aliceStaticKey
)
#expect(try responderSession.startHandshake().isEmpty)
#expect(responderSession.getState() == .handshaking)
#expect(throws: (any Error).self) {
_ = try incompleteHandshake.getTransportCiphers(useExtractedNonce: true)
}
}
@Test("Session manager callbacks establish and failed handshakes clean up state")
func sessionManagerCallbacksAndFailureCleanup() async throws {
let establishedRecorder = SessionCallbackRecorder()
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
aliceManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
bobManager.onSessionEstablished = establishedRecorder.recordEstablished(peerID:remoteKey:)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let didEstablish = await TestHelpers.waitUntil(
{ establishedRecorder.establishedCount == 2 },
timeout: 0.5
)
#expect(didEstablish)
#expect(establishedRecorder.establishedPeerIDs.contains(alicePeerID))
#expect(establishedRecorder.establishedPeerIDs.contains(bobPeerID))
let failureRecorder = SessionCallbackRecorder()
let failingManager = NoiseSessionManager(localStaticKey: charlieStaticKey, keychain: keychain)
failingManager.onSessionFailed = failureRecorder.recordFailure(peerID:error:)
#expect(throws: (any Error).self) {
try failingManager.handleIncomingHandshake(
from: charliePeerID,
message: Data(repeating: 0x00, count: 31)
)
}
let didFail = await TestHelpers.waitUntil(
{ failureRecorder.failureCount == 1 },
timeout: 0.5
)
#expect(didFail)
#expect(failingManager.getSession(for: charliePeerID) == nil)
}
@Test("Session manager cleans up initiator sessions after start-handshake failures")
func sessionManagerCleansUpInitiatorSessionsAfterStartHandshakeFailures() {
let manager = NoiseSessionManager(
localStaticKey: aliceStaticKey,
keychain: keychain,
sessionFactory: { peerID, role in
FailingNoiseSession(
peerID: peerID,
role: role,
keychain: self.keychain,
localStaticKey: self.aliceStaticKey
)
}
)
#expect(throws: FailingNoiseSession.Error.synthetic) {
try manager.initiateHandshake(with: alicePeerID)
}
#expect(manager.getSession(for: alicePeerID) == nil)
}
@Test("Session manager rekeys established sessions and replaces partial handshakes")
func sessionManagerRekeysAndReplacesSessions() throws {
let manager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
#expect(throws: NoiseSessionError.sessionNotFound) {
try manager.encrypt(Data("missing".utf8), for: alicePeerID)
}
#expect(throws: NoiseSessionError.sessionNotFound) {
try manager.decrypt(Data("missing".utf8), from: alicePeerID)
}
let initialHandshake = try manager.initiateHandshake(with: alicePeerID)
#expect(!initialHandshake.isEmpty)
let firstSession = try #require(manager.getSession(for: alicePeerID))
let restartedHandshake = try manager.initiateHandshake(with: alicePeerID)
let restartedSession = try #require(manager.getSession(for: alicePeerID))
#expect(!restartedHandshake.isEmpty)
#expect(restartedSession !== firstSession)
let restartedInitiator = NoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: bobStaticKey
)
let replacementMessage = try restartedInitiator.startHandshake()
let replacementResponse = try manager.handleIncomingHandshake(
from: alicePeerID,
message: replacementMessage
)
let replacementSession = try #require(manager.getSession(for: alicePeerID))
#expect(replacementResponse != nil)
#expect(replacementSession !== restartedSession)
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
let establishedSession = try #require(
aliceManager.getSession(for: alicePeerID) as? SecureNoiseSession
)
establishedSession.setMessageCountForTesting(
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
)
let sessionsNeedingRekey = aliceManager.getSessionsNeedingRekey()
#expect(sessionsNeedingRekey.contains { $0.peerID == alicePeerID && $0.needsRekey })
#expect(throws: NoiseSessionError.alreadyEstablished) {
try aliceManager.initiateHandshake(with: alicePeerID)
}
try aliceManager.initiateRekey(for: alicePeerID)
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
#expect(rekeyedSession !== establishedSession)
#expect(rekeyedSession.getState() == .handshaking)
}
@Test("Secure noise sessions enforce limits and renegotiation thresholds")
func secureNoiseSessionsEnforceLimitsAndThresholds() throws {
let initiator = SecureNoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responder = SecureNoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
try establishSessions(initiator: initiator, responder: responder)
responder.setMessageCountForTesting(0)
responder.setLastActivityTimeForTesting(Date())
#expect(!responder.needsRenegotiation())
responder.setMessageCountForTesting(
UInt64(Double(NoiseSecurityConstants.maxMessagesPerSession) * 0.9)
)
#expect(responder.needsRenegotiation())
responder.setMessageCountForTesting(0)
responder.setLastActivityTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(responder.needsRenegotiation())
initiator.setMessageCountForTesting(NoiseSecurityConstants.maxMessagesPerSession)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data("exhausted".utf8))
}
initiator.setMessageCountForTesting(0)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data(repeating: 0xAB, count: NoiseSecurityConstants.maxMessageSize + 1))
}
responder.setLastActivityTimeForTesting(Date())
#expect(throws: (any Error).self) {
try responder.decrypt(
Data(repeating: 0xCD, count: NoiseSecurityConstants.maxMessageSize + 1)
)
}
let transportCiphertext = try initiator.encrypt(Data("secure-session".utf8))
#expect(try responder.decrypt(transportCiphertext) == Data("secure-session".utf8))
}
@Test("Secure noise sessions expire based on session start time")
func secureNoiseSessionsExpireBasedOnSessionStartTime() throws {
let initiator = SecureNoiseSession(
peerID: alicePeerID,
role: .initiator,
keychain: keychain,
localStaticKey: aliceStaticKey
)
let responder = SecureNoiseSession(
peerID: bobPeerID,
role: .responder,
keychain: keychain,
localStaticKey: bobStaticKey
)
try establishSessions(initiator: initiator, responder: responder)
initiator.setSessionStartTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(throws: (any Error).self) {
try initiator.encrypt(Data("expired".utf8))
}
responder.setSessionStartTimeForTesting(
Date().addingTimeInterval(-(NoiseSecurityConstants.sessionTimeout + 1))
)
#expect(throws: (any Error).self) {
try responder.decrypt(Data())
}
}
@Test("Rate limiter handles global message caps and per-peer resets")
func rateLimiterGlobalMessageCapAndReset() async throws {
let globalLimiter = NoiseRateLimiter()
for index in 0..<NoiseSecurityConstants.maxGlobalMessagesPerSecond {
#expect(globalLimiter.allowMessage(from: PeerID(str: "peer-\(index)")))
}
#expect(!globalLimiter.allowMessage(from: charliePeerID))
let peerLimiter = NoiseRateLimiter()
for _ in 0..<NoiseSecurityConstants.maxMessagesPerSecond {
#expect(peerLimiter.allowMessage(from: alicePeerID))
}
#expect(!peerLimiter.allowMessage(from: alicePeerID))
peerLimiter.reset(for: alicePeerID)
try await sleep(0.05)
#expect(peerLimiter.allowMessage(from: alicePeerID))
}
@Test("Cipher state decrypts high extracted nonces and rejects truncated extracted payloads")
func cipherStateDecryptsHighExtractedNoncesAndRejectsTruncatedPayloads() throws {
let key = SymmetricKey(size: .bits256)
let receiver = NoiseCipherState(key: key, useExtractedNonce: true)
let highNoncePayload = try makeExtractedNoncePayload(
key: key,
nonce: 1_000_000_001,
plaintext: Data("high-nonce".utf8)
)
#expect(try receiver.decrypt(ciphertext: highNoncePayload) == Data("high-nonce".utf8))
#expect(throws: NoiseError.invalidCiphertext) {
try receiver.decrypt(ciphertext: extractedNoncePrefix(7))
}
}
private func establishSessions(initiator: NoiseSession, responder: NoiseSession) throws {
let message1 = try initiator.startHandshake()
let response2 = try responder.processHandshakeMessage(message1)
let message2 = try #require(response2)
let response3 = try initiator.processHandshakeMessage(message2)
let message3 = try #require(response3)
let final = try responder.processHandshakeMessage(message3)
#expect(final == nil)
}
private func establishManagerSessions(
aliceManager: NoiseSessionManager,
bobManager: NoiseSessionManager
) throws {
let message1 = try aliceManager.initiateHandshake(with: alicePeerID)
let response2 = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message1)
let message2 = try #require(response2)
let response3 = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: message2)
let message3 = try #require(response3)
let final = try bobManager.handleIncomingHandshake(from: bobPeerID, message: message3)
#expect(final == nil)
}
private func makeExtractedNoncePayload(
key: SymmetricKey,
nonce: UInt64,
plaintext: Data,
associatedData: Data = Data()
) throws -> Data {
var fullNonce = Data(count: 12)
withUnsafeBytes(of: nonce.littleEndian) { bytes in
fullNonce.replaceSubrange(4..<12, with: bytes)
}
let sealedBox = try ChaChaPoly.seal(
plaintext,
using: key,
nonce: ChaChaPoly.Nonce(data: fullNonce),
authenticating: associatedData
)
return extractedNoncePrefix(nonce) + sealedBox.ciphertext + sealedBox.tag
}
private func extractedNoncePrefix(_ nonce: UInt64) -> Data {
withUnsafeBytes(of: nonce.bigEndian) { bytes in
Data(bytes.suffix(4))
}
}
}
private final class SessionCallbackRecorder: @unchecked Sendable {
private let lock = NSLock()
private var establishedEntries: [(PeerID, Data)] = []
private var failureEntries: [(PeerID, String)] = []
var establishedCount: Int {
lock.lock()
defer { lock.unlock() }
return establishedEntries.count
}
var failureCount: Int {
lock.lock()
defer { lock.unlock() }
return failureEntries.count
}
var establishedPeerIDs: [PeerID] {
lock.lock()
defer { lock.unlock() }
return establishedEntries.map(\.0)
}
func recordEstablished(peerID: PeerID, remoteKey: Curve25519.KeyAgreement.PublicKey) {
lock.lock()
establishedEntries.append((peerID, remoteKey.rawRepresentation))
lock.unlock()
}
func recordFailure(peerID: PeerID, error: Error) {
lock.lock()
failureEntries.append((peerID, String(describing: error)))
lock.unlock()
}
}
private final class FailingNoiseSession: NoiseSession {
enum Error: Swift.Error {
case synthetic
}
override func startHandshake() throws -> Data {
throw Error.synthetic
}
}

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