mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 12:45:20 +00:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa45ee3aea | ||
|
|
e8fb4a6333 | ||
|
|
4cfcefcda6 | ||
|
|
3e137d784c | ||
|
|
452e9e74fd | ||
|
|
3afd74ffc8 | ||
|
|
951e056a55 | ||
|
|
d265cfc765 | ||
|
|
473f5c7cce | ||
|
|
b5834cfc76 | ||
|
|
097d916da7 | ||
|
|
4b000b0785 | ||
|
|
19437a0bc6 | ||
|
|
34bae4a89d | ||
|
|
0331871980 | ||
|
|
f9f6ac92b8 | ||
|
|
ca374fd823 | ||
|
|
96f2986d65 | ||
|
|
b6e45e3228 | ||
|
|
c88ab3dd05 | ||
|
|
7d83310bc2 | ||
|
|
264a95b61a | ||
|
|
8562a76367 | ||
|
|
7e86d2061f | ||
|
|
a136b5b7e9 | ||
|
|
c043cf6354 | ||
|
|
72093f9648 | ||
|
|
187a5c3195 | ||
|
|
02b2a006c5 | ||
|
|
2403a6eb90 | ||
|
|
e47c5ae7b3 | ||
|
|
c1f5868d2d | ||
|
|
0515f4c6e8 | ||
|
|
8c7e3e7b9b | ||
|
|
90a5e8ba9d | ||
|
|
6206184862 | ||
|
|
74cf0d89cc |
@@ -5,14 +5,27 @@ 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
|
||||||
|
- name: Noise
|
||||||
|
path: localPackages/Noise
|
||||||
|
- name: Nostr
|
||||||
|
path: localPackages/Nostr
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
@@ -20,8 +33,14 @@ jobs:
|
|||||||
- name: Set up Swift
|
- name: Set up Swift
|
||||||
uses: swift-actions/setup-swift@v2
|
uses: swift-actions/setup-swift@v2
|
||||||
|
|
||||||
- name: Build the package
|
- name: Cache build artifacts
|
||||||
run: swift build
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ${{ matrix.path }}/.build
|
||||||
|
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
|
${{ runner.os }}-${{ matrix.name }}-
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: swift test --parallel
|
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ plans/
|
|||||||
## AI
|
## AI
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
.claude/
|
||||||
|
|
||||||
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
|
||||||
*.xcscmblueprint
|
*.xcscmblueprint
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+13
-3
@@ -17,7 +17,10 @@ let package = Package(
|
|||||||
],
|
],
|
||||||
dependencies:[
|
dependencies:[
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
|
.package(path: "localPackages/Noise"),
|
||||||
|
.package(path: "localPackages/BitFoundation"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
|
.package(path: "localPackages/Nostr"),
|
||||||
.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")
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
@@ -25,13 +28,17 @@ 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: "Noise", package: "Noise"),
|
||||||
|
.product(name: "Nostr", package: "Nostr"),
|
||||||
.product(name: "Tor", package: "Arti")
|
.product(name: "Tor", package: "Arti")
|
||||||
],
|
],
|
||||||
path: "bitchat",
|
path: "bitchat",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"Assets.xcassets",
|
"Assets.xcassets",
|
||||||
|
"_PreviewHelpers/PreviewAssets.xcassets",
|
||||||
"bitchat.entitlements",
|
"bitchat.entitlements",
|
||||||
"bitchat-macOS.entitlements",
|
"bitchat-macOS.entitlements",
|
||||||
"LaunchScreen.storyboard",
|
"LaunchScreen.storyboard",
|
||||||
@@ -43,15 +50,18 @@ let package = Package(
|
|||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "bitchatTests",
|
name: "bitchatTests",
|
||||||
dependencies: ["bitchat"],
|
dependencies: [
|
||||||
|
"bitchat",
|
||||||
|
.product(name: "BitFoundation", package: "BitFoundation"),
|
||||||
|
.product(name: "Nostr", package: "Nostr")
|
||||||
|
],
|
||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"Info.plist",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [
|
||||||
.process("Localization"),
|
.process("Localization")
|
||||||
.process("Noise")
|
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|||||||
Generated
+66
-4
@@ -10,6 +10,12 @@
|
|||||||
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 */; };
|
||||||
|
A63163B62F80CB2500B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B52F80CB2500B8B128 /* Noise */; };
|
||||||
|
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */ = {isa = PBXBuildFile; productRef = A63163B72F80CB2D00B8B128 /* Noise */; };
|
||||||
|
A63180832F8103AB00B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180822F8103AB00B8B128 /* Nostr */; };
|
||||||
|
A63180852F8103B600B8B128 /* Nostr in Frameworks */ = {isa = PBXBuildFile; productRef = A63180842F8103B600B8B128 /* Nostr */; };
|
||||||
|
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 */; };
|
||||||
@@ -154,16 +160,22 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
A6E3E5722E7703760032EA8A /* BitLogger in Frameworks */,
|
||||||
|
A63163B82F80CB2D00B8B128 /* Noise in Frameworks */,
|
||||||
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
3EE336D150427F736F32B56C /* P256K in Frameworks */,
|
||||||
|
A63180852F8103B600B8B128 /* Nostr in Frameworks */,
|
||||||
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
A6E3EA812E7706A80032EA8A /* Tor in Frameworks */,
|
||||||
|
A6BCF94A2F809550001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
B5A5CC493FFB3D8966548140 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
files = (
|
files = (
|
||||||
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
A6E3E5702E77036A0032EA8A /* BitLogger in Frameworks */,
|
||||||
|
A63163B62F80CB2500B8B128 /* Noise in Frameworks */,
|
||||||
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
885BBED78092484A5B069461 /* P256K in Frameworks */,
|
||||||
|
A63180832F8103AB00B8B128 /* Nostr in Frameworks */,
|
||||||
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
A6E3EA7F2E7706720032EA8A /* Tor in Frameworks */,
|
||||||
|
A6BCF9482F80953E001CF9B9 /* BitFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
@@ -223,6 +235,9 @@
|
|||||||
B1D9136AA0083366353BFA2F /* P256K */,
|
B1D9136AA0083366353BFA2F /* P256K */,
|
||||||
A6E3E5712E7703760032EA8A /* BitLogger */,
|
A6E3E5712E7703760032EA8A /* BitLogger */,
|
||||||
A6E3EA802E7706A80032EA8A /* Tor */,
|
A6E3EA802E7706A80032EA8A /* Tor */,
|
||||||
|
A6BCF9492F809550001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */,
|
||||||
|
A63180842F8103B600B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_macOS;
|
productName = bitchat_macOS;
|
||||||
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
productReference = 8F3A7C058C2C8E1A06C8CF8B /* bitchat.app */;
|
||||||
@@ -303,6 +318,9 @@
|
|||||||
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
4EB6BA1B8464F1EA38F4E286 /* P256K */,
|
||||||
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
A6E3E56F2E77036A0032EA8A /* BitLogger */,
|
||||||
A6E3EA7E2E7706720032EA8A /* Tor */,
|
A6E3EA7E2E7706720032EA8A /* Tor */,
|
||||||
|
A6BCF9472F80953E001CF9B9 /* BitFoundation */,
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */,
|
||||||
|
A63180822F8103AB00B8B128 /* Nostr */,
|
||||||
);
|
);
|
||||||
productName = bitchat_iOS;
|
productName = bitchat_iOS;
|
||||||
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
productReference = 96D0D41CA19EE5A772AA8434 /* bitchat.app */;
|
||||||
@@ -344,6 +362,9 @@
|
|||||||
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" */,
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */,
|
||||||
|
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 90;
|
preferredProjectObjectVersion = 90;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -548,6 +569,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 +580,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 +630,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 +641,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 +677,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 +769,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 +930,18 @@
|
|||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCLocalSwiftPackageReference section */
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Noise;
|
||||||
|
};
|
||||||
|
A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = localPackages/Nostr;
|
||||||
|
};
|
||||||
|
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 +969,33 @@
|
|||||||
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
package = B8C407587481BBB190741C93 /* XCRemoteSwiftPackageReference "swift-secp256k1" */;
|
||||||
productName = P256K;
|
productName = P256K;
|
||||||
};
|
};
|
||||||
|
A63163B52F80CB2500B8B128 /* Noise */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Noise;
|
||||||
|
};
|
||||||
|
A63163B72F80CB2D00B8B128 /* Noise */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A63163B42F80CB2500B8B128 /* XCLocalSwiftPackageReference "localPackages/Noise" */;
|
||||||
|
productName = Noise;
|
||||||
|
};
|
||||||
|
A63180822F8103AB00B8B128 /* Nostr */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
productName = Nostr;
|
||||||
|
};
|
||||||
|
A63180842F8103B600B8B128 /* Nostr */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = A63180812F8103AB00B8B128 /* XCLocalSwiftPackageReference "localPackages/Nostr" */;
|
||||||
|
productName = Nostr;
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Tor
|
import Tor
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import BitFoundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -26,9 +28,11 @@ struct BitchatApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
private let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
GeoRelayDirectory.setupShared(dependencies: .live())
|
||||||
|
NostrRelayManager.setupShared(dependencies: .live())
|
||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
let idBridge = self.idBridge
|
let idBridge = self.idBridge
|
||||||
_chatViewModel = StateObject(
|
_chatViewModel = StateObject(
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
@Published private(set) var duration: TimeInterval = 0
|
@Published private(set) var duration: TimeInterval = 0
|
||||||
@Published private(set) var progress: Double = 0
|
@Published private(set) var progress: Double = 0
|
||||||
|
|
||||||
|
/// rounded so 4.9s shows "00:05"
|
||||||
|
var roundedDuration: Int {
|
||||||
|
guard duration.isFinite else { return 0 }
|
||||||
|
return Int(duration.rounded())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ceil so "00:01" stays visible until playback ends, capped to rounded duration
|
||||||
|
var remainingSeconds: Int {
|
||||||
|
let remaining = max(0, duration - currentTime)
|
||||||
|
return min(roundedDuration, Int(ceil(remaining)))
|
||||||
|
}
|
||||||
|
|
||||||
private var player: AVAudioPlayer?
|
private var player: AVAudioPlayer?
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var url: URL
|
private var url: URL
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import Foundation
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
|
|
||||||
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
/// Manages audio capture for mesh voice notes with predictable encoding settings.
|
||||||
/// Recording runs on an internal serial queue to avoid AVAudioSession contention.
|
actor VoiceRecorder {
|
||||||
final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
case recorderInitializationFailed
|
||||||
@@ -12,21 +11,16 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
static let shared = VoiceRecorder()
|
static let shared = VoiceRecorder()
|
||||||
|
|
||||||
private let queue = DispatchQueue(label: "com.bitchat.voice-recorder")
|
|
||||||
private let paddingInterval: TimeInterval = 0.5
|
private let paddingInterval: TimeInterval = 0.5
|
||||||
private let maxRecordingDuration: TimeInterval = 120
|
private let maxRecordingDuration: TimeInterval = 120
|
||||||
|
static let minRecordingDuration: TimeInterval = 1
|
||||||
|
|
||||||
private var recorder: AVAudioRecorder?
|
private var recorder: AVAudioRecorder?
|
||||||
private var currentURL: URL?
|
private var currentURL: URL?
|
||||||
private var stopWorkItem: DispatchWorkItem?
|
|
||||||
|
|
||||||
private override init() {
|
|
||||||
super.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
@discardableResult
|
nonisolated
|
||||||
func requestPermission() async -> Bool {
|
func requestPermission() async -> Bool {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
@@ -47,106 +41,88 @@ final class VoiceRecorder: NSObject, AVAudioRecorderDelegate {
|
|||||||
|
|
||||||
// MARK: - Recording Lifecycle
|
// MARK: - Recording Lifecycle
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
func startRecording() throws -> URL {
|
func startRecording() throws -> URL {
|
||||||
try queue.sync {
|
if recorder?.isRecording == true {
|
||||||
if recorder?.isRecording == true {
|
throw RecorderError.recordingInProgress
|
||||||
throw RecorderError.recordingInProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
guard session.recordPermission == .granted else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
// allowBluetoothHFP is not available on iOS Simulator
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
|
||||||
)
|
|
||||||
#else
|
|
||||||
try session.setCategory(
|
|
||||||
.playAndRecord,
|
|
||||||
mode: .default,
|
|
||||||
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
|
||||||
)
|
|
||||||
#endif
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
||||||
#endif
|
|
||||||
#if os(macOS)
|
|
||||||
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
|
||||||
throw RecorderError.microphoneAccessDenied
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
|
||||||
let settings: [String: Any] = [
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: 16_000,
|
|
||||||
AVNumberOfChannelsKey: 1,
|
|
||||||
AVEncoderBitRateKey: 16_000
|
|
||||||
]
|
|
||||||
|
|
||||||
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
|
||||||
audioRecorder.delegate = self
|
|
||||||
audioRecorder.isMeteringEnabled = true
|
|
||||||
audioRecorder.prepareToRecord()
|
|
||||||
audioRecorder.record(forDuration: maxRecordingDuration)
|
|
||||||
|
|
||||||
recorder = audioRecorder
|
|
||||||
currentURL = outputURL
|
|
||||||
stopWorkItem?.cancel()
|
|
||||||
stopWorkItem = nil
|
|
||||||
return outputURL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
let session = AVAudioSession.sharedInstance()
|
||||||
|
guard session.recordPermission == .granted else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
// allowBluetoothHFP is not available on iOS Simulator
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP]
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
try session.setCategory(
|
||||||
|
.playAndRecord,
|
||||||
|
mode: .default,
|
||||||
|
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||||
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
|
||||||
|
throw RecorderError.microphoneAccessDenied
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let outputURL = try makeOutputURL()
|
||||||
|
let settings: [String: Any] = [
|
||||||
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
|
AVSampleRateKey: 16_000,
|
||||||
|
AVNumberOfChannelsKey: 1,
|
||||||
|
AVEncoderBitRateKey: 16_000
|
||||||
|
]
|
||||||
|
|
||||||
|
let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
|
||||||
|
audioRecorder.isMeteringEnabled = true
|
||||||
|
audioRecorder.prepareToRecord()
|
||||||
|
audioRecorder.record(forDuration: maxRecordingDuration)
|
||||||
|
|
||||||
|
recorder = audioRecorder
|
||||||
|
currentURL = outputURL
|
||||||
|
return outputURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopRecording(completion: @escaping (URL?) -> Void) {
|
func stopRecording() async -> URL? {
|
||||||
queue.async { [weak self] in
|
guard let recorder, recorder.isRecording else {
|
||||||
guard let self = self, let recorder = self.recorder, recorder.isRecording else {
|
return currentURL
|
||||||
completion(self?.currentURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = DispatchWorkItem { [weak self] in
|
|
||||||
guard let self = self else { return }
|
|
||||||
recorder.stop()
|
|
||||||
self.cleanupSession()
|
|
||||||
let url = self.currentURL
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = url
|
|
||||||
completion(url)
|
|
||||||
}
|
|
||||||
self.stopWorkItem = item
|
|
||||||
self.queue.asyncAfter(deadline: .now() + self.paddingInterval, execute: item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sessionURL = currentURL
|
||||||
|
|
||||||
|
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
|
||||||
|
|
||||||
|
recorder.stop()
|
||||||
|
|
||||||
|
// A new session may have started during the sleep — don't touch its state
|
||||||
|
if self.recorder === recorder {
|
||||||
|
cleanupSession()
|
||||||
|
self.recorder = nil
|
||||||
|
currentURL = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessionURL
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRecording() {
|
func cancelRecording() {
|
||||||
queue.async { [weak self] in
|
if let recorder, recorder.isRecording {
|
||||||
guard let self = self else { return }
|
recorder.stop()
|
||||||
self.stopWorkItem?.cancel()
|
|
||||||
self.stopWorkItem = nil
|
|
||||||
if let recorder = self.recorder, recorder.isRecording {
|
|
||||||
recorder.stop()
|
|
||||||
}
|
|
||||||
self.cleanupSession()
|
|
||||||
if let url = self.currentURL {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
self.recorder = nil
|
|
||||||
self.currentURL = nil
|
|
||||||
}
|
}
|
||||||
}
|
cleanupSession()
|
||||||
|
if let currentURL {
|
||||||
// MARK: - Metering
|
try? FileManager.default.removeItem(at: currentURL)
|
||||||
|
|
||||||
func currentAveragePower() -> Float {
|
|
||||||
queue.sync {
|
|
||||||
recorder?.updateMeters()
|
|
||||||
return recorder?.averagePower(forChannel: 0) ?? -160
|
|
||||||
}
|
}
|
||||||
|
recorder = nil
|
||||||
|
currentURL = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - Three-Layer Identity Model
|
// MARK: - Three-Layer Identity Model
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
@@ -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
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>AppGroupID</key>
|
||||||
|
<string>$(APP_GROUP_ID)</string>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
@@ -37,12 +39,12 @@
|
|||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
|
||||||
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
|
||||||
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
|
||||||
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
|
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
|
||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>bluetooth-central</string>
|
<string>bluetooth-central</string>
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Represents a peer in the BitChat network with all associated metadata
|
/// Represents a peer in the BitChat network with all associated metadata
|
||||||
struct BitchatPeer: Equatable {
|
struct BitchatPeer: Equatable {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
struct ReadReceipt: Codable {
|
struct ReadReceipt: Codable {
|
||||||
let originalMessageID: String
|
let originalMessageID: String
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
import Tor
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
|
||||||
@MainActor
|
|
||||||
final class GeoRelayDirectory {
|
|
||||||
struct Entry: Hashable {
|
|
||||||
let host: String
|
|
||||||
let lat: Double
|
|
||||||
let lon: Double
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = GeoRelayDirectory()
|
|
||||||
|
|
||||||
private(set) var entries: [Entry] = []
|
|
||||||
private let cacheFileName = "georelays_cache.csv"
|
|
||||||
private let lastFetchKey = "georelay.lastFetchAt"
|
|
||||||
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
|
|
||||||
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds
|
|
||||||
|
|
||||||
private var refreshTimer: Timer?
|
|
||||||
private var retryTask: Task<Void, Never>?
|
|
||||||
private var retryAttempt: Int = 0
|
|
||||||
private var isFetching: Bool = false
|
|
||||||
private var observers: [NSObjectProtocol] = []
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
entries = loadLocalEntries()
|
|
||||||
registerObservers()
|
|
||||||
startRefreshTimer()
|
|
||||||
prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
observers.forEach { NotificationCenter.default.removeObserver($0) }
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
retryTask?.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
|
|
||||||
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
|
|
||||||
let center = Geohash.decodeCenter(geohash)
|
|
||||||
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
|
||||||
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
|
||||||
|
|
||||||
if entries.count <= count {
|
|
||||||
return entries
|
|
||||||
.sorted { a, b in
|
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
|
||||||
}
|
|
||||||
.map { "wss://\($0.host)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
var best: [(entry: Entry, distance: Double)] = []
|
|
||||||
best.reserveCapacity(count)
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
|
|
||||||
if best.count < count {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
} else if let worstDistance = best.last?.distance, distance < worstDistance {
|
|
||||||
let idx = best.firstIndex { $0.distance > distance } ?? best.count
|
|
||||||
best.insert((entry, distance), at: idx)
|
|
||||||
best.removeLast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return best.map { "wss://\($0.entry.host)" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
|
||||||
func prefetchIfNeeded(force: Bool = false) {
|
|
||||||
guard !isFetching else { return }
|
|
||||||
|
|
||||||
let now = Date()
|
|
||||||
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
|
|
||||||
|
|
||||||
if !force {
|
|
||||||
guard now.timeIntervalSince(last) >= fetchInterval else { return }
|
|
||||||
} else if last != .distantPast,
|
|
||||||
now.timeIntervalSince(last) < TransportConfig.geoRelayRetryInitialSeconds {
|
|
||||||
// Skip forced fetches if we just refreshed moments ago.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
fetchRemote()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchRemote() {
|
|
||||||
guard !isFetching else { return }
|
|
||||||
isFetching = true
|
|
||||||
|
|
||||||
let request = URLRequest(
|
|
||||||
url: remoteURL,
|
|
||||||
cachePolicy: .reloadIgnoringLocalCacheData,
|
|
||||||
timeoutInterval: 15
|
|
||||||
)
|
|
||||||
|
|
||||||
Task.detached { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
|
|
||||||
let ready = await TorManager.shared.awaitReady()
|
|
||||||
if !ready {
|
|
||||||
await self.handleFetchFailure(.torNotReady)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, _) = try await TorURLSession.shared.session.data(for: request)
|
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed = GeoRelayDirectory.parseCSV(text)
|
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
await self.handleFetchFailure(.invalidData)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.handleFetchSuccess(entries: parsed, csv: text)
|
|
||||||
} catch {
|
|
||||||
await self.handleFetchFailure(.network(error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum FetchFailure {
|
|
||||||
case torNotReady
|
|
||||||
case invalidData
|
|
||||||
case network(Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
|
||||||
entries = parsed
|
|
||||||
persistCache(csv)
|
|
||||||
UserDefaults.standard.set(Date(), forKey: lastFetchKey)
|
|
||||||
SecureLogger.info("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: .session)
|
|
||||||
isFetching = false
|
|
||||||
retryAttempt = 0
|
|
||||||
cancelRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func handleFetchFailure(_ reason: FetchFailure) {
|
|
||||||
switch reason {
|
|
||||||
case .torNotReady:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: Tor not ready; scheduling retry", category: .session)
|
|
||||||
case .invalidData:
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch returned invalid data; scheduling retry", category: .session)
|
|
||||||
case .network(let error):
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: remote fetch failed with error: \(error.localizedDescription)", category: .session)
|
|
||||||
}
|
|
||||||
isFetching = false
|
|
||||||
scheduleRetry()
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryAttempt = min(retryAttempt + 1, 10)
|
|
||||||
let base = TransportConfig.geoRelayRetryInitialSeconds
|
|
||||||
let maxDelay = TransportConfig.geoRelayRetryMaxSeconds
|
|
||||||
let multiplier = pow(2.0, Double(max(retryAttempt - 1, 0)))
|
|
||||||
let calculated = base * multiplier
|
|
||||||
let delay = min(maxDelay, max(base, calculated))
|
|
||||||
|
|
||||||
cancelRetry()
|
|
||||||
retryTask = Task { [weak self] in
|
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
|
||||||
await MainActor.run {
|
|
||||||
self?.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func cancelRetry() {
|
|
||||||
retryTask?.cancel()
|
|
||||||
retryTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
|
||||||
guard let url = cacheURL() else { return }
|
|
||||||
do {
|
|
||||||
try text.data(using: .utf8)?.write(to: url, options: .atomic)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: failed to write cache: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Loading
|
|
||||||
private func loadLocalEntries() -> [Entry] {
|
|
||||||
// Prefer cached file if present
|
|
||||||
if let cache = cacheURL(),
|
|
||||||
let data = try? Data(contentsOf: cache),
|
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
|
||||||
if !arr.isEmpty { return arr }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try bundled resource(s)
|
|
||||||
let bundleCandidates = [
|
|
||||||
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
|
|
||||||
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
|
|
||||||
].compactMap { $0 }
|
|
||||||
|
|
||||||
for url in bundleCandidates {
|
|
||||||
if let data = try? Data(contentsOf: url),
|
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
let arr = Self.parseCSV(text)
|
|
||||||
if !arr.isEmpty { return arr }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try filesystem path (development/test)
|
|
||||||
if let cwd = FileManager.default.currentDirectoryPath as String?,
|
|
||||||
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
|
||||||
let text = String(data: data, encoding: .utf8) {
|
|
||||||
return Self.parseCSV(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
|
||||||
var result: Set<Entry> = []
|
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
|
||||||
for (idx, raw) in lines.enumerated() {
|
|
||||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
if line.isEmpty { continue }
|
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
|
||||||
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
|
|
||||||
guard parts.count >= 3 else { continue }
|
|
||||||
var host = parts[0]
|
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "http://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
|
||||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
|
||||||
}
|
|
||||||
return Array(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cacheURL() -> URL? {
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
||||||
return dir.appendingPathComponent(cacheFileName)
|
|
||||||
} catch {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Observers & Timers
|
|
||||||
private func registerObservers() {
|
|
||||||
let center = NotificationCenter.default
|
|
||||||
|
|
||||||
let torReady = center.addObserver(
|
|
||||||
forName: .TorDidBecomeReady,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
observers.append(torReady)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
let didBecomeActive = center.addObserver(
|
|
||||||
forName: UIApplication.didBecomeActiveNotification,
|
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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() {
|
|
||||||
refreshTimer?.invalidate()
|
|
||||||
let interval = TransportConfig.geoRelayRefreshCheckIntervalSeconds
|
|
||||||
guard interval > 0 else { return }
|
|
||||||
|
|
||||||
let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self else { return }
|
|
||||||
Task { @MainActor in
|
|
||||||
self.prefetchIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
refreshTimer = timer
|
|
||||||
RunLoop.main.add(timer, forMode: .common)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Distance
|
|
||||||
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
|
|
||||||
let r = 6371.0 // Earth radius in km
|
|
||||||
let dLat = (lat2 - lat1) * .pi / 180
|
|
||||||
let dLon = (lon2 - lon1) * .pi / 180
|
|
||||||
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
|
|
||||||
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
||||||
return r * c
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - BitChat-over-Nostr Adapter
|
// MARK: - BitChat-over-Nostr Adapter
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Nostr
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import Tor
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - GeoRelayDirectory Live Dependencies
|
||||||
|
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
torReadyNotificationName: .TorDidBecomeReady,
|
||||||
|
activeNotificationName: activeNotificationName,
|
||||||
|
autoStart: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NostrRelayManager Live Dependencies
|
||||||
|
|
||||||
|
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
|
||||||
|
.map { state -> LocationPermissionState in
|
||||||
|
switch state {
|
||||||
|
case .notDetermined:.notDetermined
|
||||||
|
case .authorized: .authorized
|
||||||
|
case .denied: .denied
|
||||||
|
case .restricted: .denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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: { NostrRelayManager.makeURLSession(TorURLSession.shared.session) },
|
||||||
|
scheduleAfter: { delay, action in
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
|
},
|
||||||
|
now: Date.init
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// MARK: - Message Types
|
// MARK: - Message Types
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
import Combine
|
import Combine
|
||||||
@@ -7,7 +9,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 +253,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 +297,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 +527,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 +537,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 +737,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 +748,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,40 +834,39 @@ 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)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
if skew > maxSkew {
|
if skew > maxSkew {
|
||||||
SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))…", category: .security)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1165,13 +1178,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 +1224,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 +1243,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 +1356,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 +2031,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)
|
||||||
@@ -2192,6 +2178,13 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
if result.reset {
|
if result.reset {
|
||||||
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 {
|
||||||
@@ -2199,11 +2192,32 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
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 +2632,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)
|
|
||||||
|
let claimedSenderID = PeerID(hexData: packet.senderID)
|
||||||
// Validate packet (Timestamp/RSR)
|
|
||||||
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 +2666,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 +3834,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 +4082,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 +4097,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Result of command processing
|
/// Result of command processing
|
||||||
enum CommandResult {
|
enum CommandResult {
|
||||||
@@ -166,7 +168,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
private func handleEmote(_ args: String, command: String, action: String, emoji: String, suffix: String = "") -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(command) <nickname>")
|
return .error(message: "usage: /\(command) <nickname>")
|
||||||
}
|
}
|
||||||
@@ -209,7 +211,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleBlock(_ args: String) -> CommandResult {
|
private func handleBlock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
|
|
||||||
if targetName.isEmpty {
|
if targetName.isEmpty {
|
||||||
// List blocked users (mesh) and geohash (Nostr) blocks
|
// List blocked users (mesh) and geohash (Nostr) blocks
|
||||||
@@ -284,7 +286,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleUnblock(_ args: String) -> CommandResult {
|
private func handleUnblock(_ args: String) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /unblock <nickname>")
|
return .error(message: "usage: /unblock <nickname>")
|
||||||
}
|
}
|
||||||
@@ -311,7 +313,7 @@ final class CommandProcessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmingCharacters(in: .whitespaces)
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,31 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
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 +45,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 +75,74 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
]
|
]
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
|
let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
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 +154,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 +162,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 +191,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 +233,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 +252,10 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Send via RelayManager
|
// Send via RelayManager
|
||||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
let targetRelays = relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
toGeohash: geohash,
|
|
||||||
count: TransportConfig.nostrGeoRelayCount
|
|
||||||
)
|
|
||||||
|
|
||||||
if !targetRelays.isEmpty {
|
if !targetRelays.isEmpty {
|
||||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
relaySender(event, targetRelays)
|
||||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import protocol Nostr.NostrKeychainStoring
|
||||||
|
import protocol Noise.SecureMemoryCleaner
|
||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ enum KeychainSaveResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol KeychainManagerProtocol {
|
protocol KeychainManagerProtocol: SecureMemoryCleaner, NostrKeychainStoring {
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
|
||||||
func getIdentityKey(forKey key: String) -> Data?
|
func getIdentityKey(forKey key: String) -> Data?
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool
|
func deleteIdentityKey(forKey key: String) -> Bool
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
@@ -15,7 +16,7 @@ struct LocationNotesDependencies {
|
|||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
var now: () -> Date
|
||||||
|
|
||||||
private static let idBridge = NostrIdentityBridge()
|
private static let idBridge = NostrIdentityBridge(keychain: KeychainManager())
|
||||||
|
|
||||||
static let live = LocationNotesDependencies(
|
static let live = LocationNotesDependencies(
|
||||||
relayLookup: { geohash, count in
|
relayLookup: { geohash, count in
|
||||||
@@ -63,7 +64,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 +200,7 @@ final class LocationNotesManager: ObservableObject {
|
|||||||
|
|
||||||
/// Send a location note for the current geohash using the per-geohash identity.
|
/// Send a location note for the current geohash using the per-geohash identity.
|
||||||
func send(content: String, nickname: String) {
|
func send(content: String, nickname: String) {
|
||||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
guard !trimmed.isEmpty else { return }
|
|
||||||
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount)
|
||||||
guard !relays.isEmpty else {
|
guard !relays.isEmpty else {
|
||||||
state = .noRelays
|
state = .noRelays
|
||||||
|
|||||||
@@ -5,6 +5,79 @@ import Combine
|
|||||||
#if os(iOS) || os(macOS)
|
#if os(iOS) || os(macOS)
|
||||||
import CoreLocation
|
import CoreLocation
|
||||||
|
|
||||||
|
protocol LocationStateManaging: AnyObject {
|
||||||
|
var delegate: CLLocationManagerDelegate? { get set }
|
||||||
|
var desiredAccuracy: CLLocationAccuracy { get set }
|
||||||
|
var distanceFilter: CLLocationDistance { get set }
|
||||||
|
var authorizationStatus: CLAuthorizationStatus { get }
|
||||||
|
func requestWhenInUseAuthorization()
|
||||||
|
func requestLocation()
|
||||||
|
func startUpdatingLocation()
|
||||||
|
func stopUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol LocationStateGeocoding: AnyObject {
|
||||||
|
func cancelGeocode()
|
||||||
|
func reverseGeocodeLocation(
|
||||||
|
_ location: CLLocation,
|
||||||
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CLLocationManagerAdapter: NSObject, LocationStateManaging {
|
||||||
|
private let base = CLLocationManager()
|
||||||
|
|
||||||
|
var delegate: CLLocationManagerDelegate? {
|
||||||
|
get { base.delegate }
|
||||||
|
set { base.delegate = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var desiredAccuracy: CLLocationAccuracy {
|
||||||
|
get { base.desiredAccuracy }
|
||||||
|
set { base.desiredAccuracy = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var distanceFilter: CLLocationDistance {
|
||||||
|
get { base.distanceFilter }
|
||||||
|
set { base.distanceFilter = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
var authorizationStatus: CLAuthorizationStatus {
|
||||||
|
base.authorizationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestWhenInUseAuthorization() {
|
||||||
|
base.requestWhenInUseAuthorization()
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestLocation() {
|
||||||
|
base.requestLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startUpdatingLocation() {
|
||||||
|
base.startUpdatingLocation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopUpdatingLocation() {
|
||||||
|
base.stopUpdatingLocation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class CLGeocoderAdapter: LocationStateGeocoding {
|
||||||
|
private let base = CLGeocoder()
|
||||||
|
|
||||||
|
func cancelGeocode() {
|
||||||
|
base.cancelGeocode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseGeocodeLocation(
|
||||||
|
_ location: CLLocation,
|
||||||
|
completionHandler: @escaping ([CLPlacemark]?, Error?) -> Void
|
||||||
|
) {
|
||||||
|
base.reverseGeocodeLocation(location, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unified manager for location-based channel state including:
|
/// Unified manager for location-based channel state including:
|
||||||
/// - CoreLocation permissions and one-shot location retrieval
|
/// - CoreLocation permissions and one-shot location retrieval
|
||||||
/// - Geohash channel computation from coordinates
|
/// - Geohash channel computation from coordinates
|
||||||
@@ -26,8 +99,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
// MARK: - Private Properties (CoreLocation)
|
// MARK: - Private Properties (CoreLocation)
|
||||||
|
|
||||||
private let cl = CLLocationManager()
|
private let cl: LocationStateManaging
|
||||||
private let geocoder = CLGeocoder()
|
private let geocoder: LocationStateGeocoding
|
||||||
private var lastLocation: CLLocation?
|
private var lastLocation: CLLocation?
|
||||||
private var refreshTimer: Timer?
|
private var refreshTimer: Timer?
|
||||||
private var isGeocoding: Bool = false
|
private var isGeocoding: Bool = false
|
||||||
@@ -73,6 +146,8 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
|
|
||||||
private override init() {
|
private override init() {
|
||||||
self.storage = .standard
|
self.storage = .standard
|
||||||
|
self.cl = CLLocationManagerAdapter()
|
||||||
|
self.geocoder = CLGeocoderAdapter()
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
// Skip CoreLocation setup in test environments
|
// Skip CoreLocation setup in test environments
|
||||||
@@ -92,10 +167,30 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
/// Internal initializer for testing with custom storage
|
/// Internal initializer for testing with custom storage
|
||||||
init(storage: UserDefaults) {
|
init(storage: UserDefaults) {
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
|
self.cl = CLLocationManagerAdapter()
|
||||||
|
self.geocoder = CLGeocoderAdapter()
|
||||||
super.init()
|
super.init()
|
||||||
loadPersistedState()
|
loadPersistedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
storage: UserDefaults,
|
||||||
|
locationManager: LocationStateManaging,
|
||||||
|
geocoder: LocationStateGeocoding,
|
||||||
|
shouldInitializeCoreLocation: Bool
|
||||||
|
) {
|
||||||
|
self.storage = storage
|
||||||
|
self.cl = locationManager
|
||||||
|
self.geocoder = geocoder
|
||||||
|
super.init()
|
||||||
|
loadPersistedState()
|
||||||
|
guard shouldInitializeCoreLocation else { return }
|
||||||
|
cl.delegate = self
|
||||||
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||||
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
||||||
|
initializePermissionState()
|
||||||
|
}
|
||||||
|
|
||||||
private func loadPersistedState() {
|
private func loadPersistedState() {
|
||||||
// Load selected channel
|
// Load selected channel
|
||||||
if let data = storage.data(forKey: selectedChannelKey),
|
if let data = storage.data(forKey: selectedChannelKey),
|
||||||
@@ -132,12 +227,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func initializePermissionState() {
|
private func initializePermissionState() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
updatePermissionState(from: status)
|
updatePermissionState(from: status)
|
||||||
|
|
||||||
// Fall back to persisted teleport state if no location authorization
|
// Fall back to persisted teleport state if no location authorization
|
||||||
@@ -156,12 +246,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
// MARK: - Public API (Permissions & Location)
|
// MARK: - Public API (Permissions & Location)
|
||||||
|
|
||||||
func enableLocationChannels() {
|
func enableLocationChannels() {
|
||||||
let status: CLAuthorizationStatus
|
let status = cl.authorizationStatus
|
||||||
if #available(iOS 14.0, macOS 11.0, *) {
|
|
||||||
status = cl.authorizationStatus
|
|
||||||
} else {
|
|
||||||
status = CLLocationManager.authorizationStatus()
|
|
||||||
}
|
|
||||||
switch status {
|
switch status {
|
||||||
case .notDetermined:
|
case .notDetermined:
|
||||||
cl.requestWhenInUseAuthorization()
|
cl.requestWhenInUseAuthorization()
|
||||||
@@ -512,7 +597,7 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
|
|||||||
private static func normalizeGeohash(_ s: String) -> String {
|
private static func normalizeGeohash(_ s: String) -> String {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
return s
|
return s
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmed
|
||||||
.lowercased()
|
.lowercased()
|
||||||
.replacingOccurrences(of: "#", with: "")
|
.replacingOccurrences(of: "#", with: "")
|
||||||
.filter { allowed.contains($0) }
|
.filter { allowed.contains($0) }
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ enum ContentNormalizer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trim and collapse whitespace
|
// Trim and collapse whitespace
|
||||||
let trimmed = simplified.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = simplified.trimmed
|
||||||
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
let collapsed = trimmed.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||||
|
|
||||||
// Take prefix and hash
|
// Take prefix and hash
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// This is free and unencumbered software released into the public domain.
|
// This is free and unencumbered software released into the public domain.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
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 +39,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 +97,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 +114,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 +125,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 +145,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,8 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,37 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
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 +45,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 +75,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 +93,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 +161,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
|
||||||
@@ -143,7 +184,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||||
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…", category: .session)
|
||||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
@@ -159,7 +200,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))…", category: .session)
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
|
||||||
@@ -229,9 +270,9 @@ extension NostrTransport {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if registerPending {
|
if registerPending {
|
||||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
dependencies.registerPendingGiftWrap(event.id)
|
||||||
}
|
}
|
||||||
NostrRelayManager.shared.sendEvent(event)
|
dependencies.sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Must be called within a barrier on `queue`
|
/// Must be called within a barrier on `queue`
|
||||||
@@ -249,7 +290,7 @@ extension NostrTransport {
|
|||||||
defer { scheduleNextReadAck() }
|
defer { scheduleNextReadAck() }
|
||||||
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
|
||||||
let recipientHex = npubToHex(recipientNpub),
|
let recipientHex = npubToHex(recipientNpub),
|
||||||
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
|
let senderIdentity = try? dependencies.currentIdentity() else { return }
|
||||||
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||||
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
|
||||||
@@ -260,7 +301,7 @@ extension NostrTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleNextReadAck() {
|
private func scheduleNextReadAck() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
dependencies.scheduleAfter(readAckInterval) { [weak self] in
|
||||||
self?.queue.async(flags: .barrier) { [weak self] in
|
self?.queue.async(flags: .barrier) { [weak self] in
|
||||||
self?.isSendingReadAcks = false
|
self?.isSendingReadAcks = false
|
||||||
self?.processReadQueueIfNeeded()
|
self?.processReadQueueIfNeeded()
|
||||||
@@ -271,12 +312,12 @@ extension NostrTransport {
|
|||||||
@MainActor
|
@MainActor
|
||||||
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
private func resolveRecipientNpub(for peerID: PeerID) -> String? {
|
||||||
if let noiseKey = Data(hexString: peerID.id),
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
let fav = dependencies.favoriteStatusForNoiseKey(noiseKey),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
if peerID.id.count == 16,
|
if peerID.id.count == 16,
|
||||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
|
let fav = dependencies.favoriteStatusForPeerID(peerID),
|
||||||
let npub = fav.peerNostrPublicKey {
|
let npub = fav.peerNostrPublicKey {
|
||||||
return npub
|
return npub
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -14,24 +15,103 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
protocol NotificationAuthorizing {
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol NotificationRequestDelivering {
|
||||||
|
func add(_ request: UNNotificationRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NotificationCenterAuthorizerAdapter: NotificationAuthorizing {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
) {
|
||||||
|
center.requestAuthorization(options: options, completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class NotificationCenterRequestDelivererAdapter: NotificationRequestDelivering {
|
||||||
|
private let center: UNUserNotificationCenter
|
||||||
|
|
||||||
|
init(center: UNUserNotificationCenter) {
|
||||||
|
self.center = center
|
||||||
|
}
|
||||||
|
|
||||||
|
func add(_ request: UNNotificationRequest) {
|
||||||
|
Task {
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NoopNotificationAuthorizer: NotificationAuthorizing {
|
||||||
|
func requestAuthorization(
|
||||||
|
options: UNAuthorizationOptions,
|
||||||
|
completionHandler: @escaping (Bool, Error?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(false, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NoopNotificationRequestDeliverer: NotificationRequestDelivering {
|
||||||
|
func add(_ request: UNNotificationRequest) {}
|
||||||
|
}
|
||||||
|
|
||||||
final class NotificationService {
|
final class NotificationService {
|
||||||
static let shared = NotificationService()
|
static let shared = NotificationService()
|
||||||
|
|
||||||
|
private let isRunningTestsProvider: () -> Bool
|
||||||
|
private let authorizer: NotificationAuthorizing
|
||||||
|
private let requestDeliverer: NotificationRequestDelivering
|
||||||
|
|
||||||
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
/// Returns true if running in test environment (XCTest, Swift Testing, or CI)
|
||||||
private var isRunningTests: Bool {
|
private var isRunningTests: Bool {
|
||||||
let env = ProcessInfo.processInfo.environment
|
isRunningTestsProvider()
|
||||||
return NSClassFromString("XCTestCase") != nil ||
|
|
||||||
env["XCTestConfigurationFilePath"] != nil ||
|
|
||||||
env["XCTestBundlePath"] != nil ||
|
|
||||||
env["GITHUB_ACTIONS"] != nil ||
|
|
||||||
env["CI"] != nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {
|
||||||
|
self.isRunningTestsProvider = {
|
||||||
|
let env = ProcessInfo.processInfo.environment
|
||||||
|
return NSClassFromString("XCTestCase") != nil ||
|
||||||
|
env["XCTestConfigurationFilePath"] != nil ||
|
||||||
|
env["XCTestBundlePath"] != nil ||
|
||||||
|
env["GITHUB_ACTIONS"] != nil ||
|
||||||
|
env["CI"] != nil
|
||||||
|
}
|
||||||
|
if isRunningTestsProvider() {
|
||||||
|
self.authorizer = NoopNotificationAuthorizer()
|
||||||
|
self.requestDeliverer = NoopNotificationRequestDeliverer()
|
||||||
|
} else {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
self.authorizer = NotificationCenterAuthorizerAdapter(center: center)
|
||||||
|
self.requestDeliverer = NotificationCenterRequestDelivererAdapter(center: center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
isRunningTestsProvider: @escaping () -> Bool,
|
||||||
|
authorizer: NotificationAuthorizing,
|
||||||
|
requestDeliverer: NotificationRequestDelivering
|
||||||
|
) {
|
||||||
|
self.isRunningTestsProvider = isRunningTestsProvider
|
||||||
|
self.authorizer = authorizer
|
||||||
|
self.requestDeliverer = requestDeliverer
|
||||||
|
}
|
||||||
|
|
||||||
func requestAuthorization() {
|
func requestAuthorization() {
|
||||||
guard !isRunningTests else { return }
|
guard !isRunningTests else { return }
|
||||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||||
if granted {
|
if granted {
|
||||||
// Permission granted
|
// Permission granted
|
||||||
} else {
|
} else {
|
||||||
@@ -64,7 +144,7 @@ final class NotificationService {
|
|||||||
trigger: nil // Deliver immediately
|
trigger: nil // Deliver immediately
|
||||||
)
|
)
|
||||||
|
|
||||||
UNUserNotificationCenter.current().add(request)
|
requestDeliverer.add(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMentionNotification(from sender: String, message: String) {
|
func sendMentionNotification(from sender: String, message: String) {
|
||||||
@@ -96,7 +176,8 @@ final class NotificationService {
|
|||||||
func sendNetworkAvailableNotification(peerCount: Int) {
|
func sendNetworkAvailableNotification(peerCount: Int) {
|
||||||
let title = "👥 bitchatters nearby!"
|
let title = "👥 bitchatters nearby!"
|
||||||
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
let body = peerCount == 1 ? "1 person around" : "\(peerCount) people around"
|
||||||
let identifier = "network-available-\(Date().timeIntervalSince1970)"
|
// Fixed identifier so iOS updates the existing notification instead of creating new ones
|
||||||
|
let identifier = "network-available"
|
||||||
|
|
||||||
sendLocalNotification(
|
sendLocalNotification(
|
||||||
title: title,
|
title: title,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
|||||||
@@ -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,8 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
// Gossip-based sync manager using on-demand GCS filters
|
// Gossip-based sync manager using on-demand GCS filters
|
||||||
final class GossipSyncManager {
|
final class GossipSyncManager {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
/// Manages outgoing sync requests and validates incoming responses.
|
/// Manages outgoing sync requests and validates incoming responses.
|
||||||
///
|
///
|
||||||
@@ -17,14 +18,21 @@ final class RequestSyncManager {
|
|||||||
|
|
||||||
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
|
||||||
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
private var pendingRequests: [PeerID: TimeInterval] = [:]
|
||||||
|
private let responseWindow: TimeInterval
|
||||||
|
private let now: () -> TimeInterval
|
||||||
|
|
||||||
// Allow responses for 30s after request
|
init(
|
||||||
private let responseWindow: TimeInterval = 30.0
|
responseWindow: TimeInterval = 30.0,
|
||||||
|
now: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }
|
||||||
|
) {
|
||||||
|
self.responseWindow = responseWindow
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
|
||||||
/// Register that we are sending a sync request to a peer.
|
/// Register that we are sending a sync request to a peer.
|
||||||
/// - Parameter peerID: The peer we are requesting sync from
|
/// - Parameter peerID: The peer we are requesting sync from
|
||||||
func registerRequest(to peerID: PeerID) {
|
func registerRequest(to peerID: PeerID) {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))…", category: .sync)
|
||||||
self.pendingRequests[peerID] = now
|
self.pendingRequests[peerID] = now
|
||||||
@@ -46,7 +54,7 @@ final class RequestSyncManager {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
if now - requestTime > responseWindow {
|
if now - requestTime > responseWindow {
|
||||||
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
|
||||||
// We don't remove here because we might receive multiple packets for one request
|
// We don't remove here because we might receive multiple packets for one request
|
||||||
@@ -59,7 +67,7 @@ final class RequestSyncManager {
|
|||||||
|
|
||||||
/// Periodic cleanup of expired requests
|
/// Periodic cleanup of expired requests
|
||||||
func cleanup() {
|
func cleanup() {
|
||||||
let now = Date().timeIntervalSince1970
|
let now = self.now()
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
let originalCount = self.pendingRequests.count
|
let originalCount = self.pendingRequests.count
|
||||||
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
|
||||||
@@ -71,4 +79,8 @@ final class RequestSyncManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugPendingRequestCount: Int {
|
||||||
|
queue.sync { pendingRequests.count }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@
|
|||||||
///
|
///
|
||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Combine
|
import Combine
|
||||||
@@ -144,10 +146,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 +750,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 +766,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 +990,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("/") {
|
||||||
@@ -1810,7 +1801,15 @@ 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] = []
|
||||||
@@ -1969,7 +1968,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
||||||
|
|
||||||
// Reinitialize Nostr relay manager with new identity
|
// Reinitialize Nostr relay manager with new identity
|
||||||
nostrRelayManager = NostrRelayManager()
|
nostrRelayManager = NostrRelayManager(dependencies: .live())
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
nostrRelayManager?.connect()
|
nostrRelayManager?.connect()
|
||||||
}
|
}
|
||||||
@@ -3002,7 +3001,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 +3174,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 +3347,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 +3782,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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
// Geohash and Nostr logic for ChatViewModel
|
// Geohash and Nostr logic for ChatViewModel
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Tor
|
import Tor
|
||||||
|
|
||||||
@@ -56,6 +58,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 +79,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,8 +117,8 @@ 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))
|
||||||
let timestamp = min(rawTs, Date())
|
let timestamp = min(rawTs, Date())
|
||||||
@@ -146,13 +149,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 +194,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 +256,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 +310,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 +326,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 +371,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 +385,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 +488,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,9 +499,8 @@ extension ChatViewModel {
|
|||||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||||
|
|
||||||
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
// Notify only on rising-edge: previously zero people, now someone sends a chat
|
||||||
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 +605,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 +623,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 +634,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 +803,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? {
|
||||||
|
|||||||
@@ -5,9 +5,11 @@
|
|||||||
// Private chat and media transfer logic for ChatViewModel
|
// Private chat and media transfer logic for ChatViewModel
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import BitLogger
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
extension ChatViewModel {
|
extension ChatViewModel {
|
||||||
@@ -319,7 +321,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 +366,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 +430,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 +549,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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
// Created by Islam on 29/10/2025.
|
// Created by Islam on 29/10/2025.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct CommandSuggestionsView: View {
|
struct CommandSuggestionsView: View {
|
||||||
@@ -76,7 +77,7 @@ struct CommandSuggestionsView: View {
|
|||||||
let keychain = KeychainManager()
|
let keychain = KeychainManager()
|
||||||
let viewModel = ChatViewModel(
|
let viewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: NostrIdentityBridge(),
|
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct TextMessageView: View {
|
struct TextMessageView: View {
|
||||||
@@ -13,7 +14,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 +67,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 +80,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())
|
||||||
@@ -91,7 +90,7 @@ struct TextMessageView: View {
|
|||||||
.environmentObject(
|
.environmentObject(
|
||||||
ChatViewModel(
|
ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: NostrIdentityBridge(),
|
idBridge: NostrIdentityBridge(keychain: keychain),
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
identityManager: SecureIdentityStateManager(keychain)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
+97
-1009
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import BitFoundation
|
||||||
|
|
||||||
struct MeshPeerList: View {
|
struct MeshPeerList: View {
|
||||||
@ObservedObject var viewModel: ChatViewModel
|
@ObservedObject var viewModel: ChatViewModel
|
||||||
|
|||||||
@@ -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()
|
||||||
|
//}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Nostr
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreImage
|
import CoreImage
|
||||||
import CoreImage.CIFilterBuiltins
|
import CoreImage.CIFilterBuiltins
|
||||||
|
|||||||
@@ -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 |
@@ -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/>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -6,8 +6,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct BLEServiceCoreTests {
|
struct BLEServiceCoreTests {
|
||||||
@@ -23,10 +25,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 +59,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 {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct BLEServiceTests {
|
struct BLEServiceTests {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
// MARK: - Test Helpers
|
||||||
|
|||||||
@@ -6,8 +6,15 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
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 +272,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 +285,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 +311,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 +328,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 +353,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 +411,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 +724,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct ChatViewModelRefactoringTests {
|
struct ChatViewModelRefactoringTests {
|
||||||
@@ -125,26 +127,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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
// MARK: - Test Helpers
|
||||||
@@ -195,10 +197,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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
import Nostr
|
||||||
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 +22,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 +35,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 +45,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,8 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import struct Foundation.UUID
|
import struct Foundation.UUID
|
||||||
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,10 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreBluetooth
|
import CoreBluetooth
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct FragmentationTests {
|
struct FragmentationTests {
|
||||||
@@ -28,7 +30,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 +66,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 +101,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 +158,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 +211,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 +259,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 +298,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 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
@@ -131,7 +132,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 +140,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 +161,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 +170,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 +188,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 +224,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 +251,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 +259,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,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct IntegrationTests {
|
struct IntegrationTests {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
|
import BitFoundation
|
||||||
|
import Noise
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class TestNetworkHelper {
|
final class TestNetworkHelper {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@@ -44,4 +45,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,53 @@
|
|||||||
import Testing
|
import Testing
|
||||||
|
import Nostr
|
||||||
import Foundation
|
import Foundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@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 +70,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 +87,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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
final class MockBLEBus {
|
final class MockBLEBus {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -108,6 +109,11 @@ final class MockBLEService: NSObject {
|
|||||||
func getPeers() -> [PeerID: String] {
|
func getPeers() -> [PeerID: String] {
|
||||||
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(
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import Foundation
|
||||||
|
import Nostr
|
||||||
|
import Tor
|
||||||
|
import XCTest
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class GeoRelayDirectoryTests: XCTestCase {
|
||||||
|
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||||
|
let csv = """
|
||||||
|
relay url,lat,lon
|
||||||
|
wss://one.example/,10,20
|
||||||
|
https://one.example,10,20
|
||||||
|
http://two.example/,11,21
|
||||||
|
invalid row
|
||||||
|
ws://three.example,not-a-lat,22
|
||||||
|
"""
|
||||||
|
|
||||||
|
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
parsed,
|
||||||
|
Set([
|
||||||
|
GeoRelayDirectory.Entry(host: "one.example", lat: 10, lon: 20),
|
||||||
|
GeoRelayDirectory.Entry(host: "two.example", lat: 11, lon: 21)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||||
|
let harness = makeHarness(
|
||||||
|
cacheCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
close.example,37.7749,-122.4194
|
||||||
|
medium.example,34.0522,-118.2437
|
||||||
|
far.example,40.7128,-74.0060
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
XCTAssertEqual(
|
||||||
|
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 2),
|
||||||
|
["wss://close.example", "wss://medium.example"]
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
directory.closestRelays(toLat: 37.78, lon: -122.41, count: 10),
|
||||||
|
["wss://close.example", "wss://medium.example", "wss://far.example"]
|
||||||
|
)
|
||||||
|
|
||||||
|
let geohash = Geohash.encode(latitude: 37.78, longitude: -122.41, precision: 6)
|
||||||
|
XCTAssertEqual(
|
||||||
|
directory.closestRelays(toGeohash: geohash, count: 2),
|
||||||
|
["wss://close.example", "wss://medium.example"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_loadLocalEntries_prefersCacheThenBundleThenWorkingDirectory() {
|
||||||
|
let cacheHarness = makeHarness(
|
||||||
|
cacheCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
cache.example,1,1
|
||||||
|
""",
|
||||||
|
bundleCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
bundle.example,2,2
|
||||||
|
""",
|
||||||
|
workingDirectoryCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
cwd.example,3,3
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GeoRelayDirectory(dependencies: cacheHarness.dependencies).entries,
|
||||||
|
[GeoRelayDirectory.Entry(host: "cache.example", lat: 1, lon: 1)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let bundleHarness = makeHarness(
|
||||||
|
cacheCSV: "invalid",
|
||||||
|
bundleCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
bundle.example,2,2
|
||||||
|
""",
|
||||||
|
workingDirectoryCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
cwd.example,3,3
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GeoRelayDirectory(dependencies: bundleHarness.dependencies).entries,
|
||||||
|
[GeoRelayDirectory.Entry(host: "bundle.example", lat: 2, lon: 2)]
|
||||||
|
)
|
||||||
|
|
||||||
|
let cwdHarness = makeHarness(
|
||||||
|
cacheCSV: nil,
|
||||||
|
bundleCSV: "invalid",
|
||||||
|
workingDirectoryCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
cwd.example,3,3
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
GeoRelayDirectory(dependencies: cwdHarness.dependencies).entries,
|
||||||
|
[GeoRelayDirectory.Entry(host: "cwd.example", lat: 3, lon: 3)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_prefetchIfNeeded_skipsWhenFetchIntervalHasNotElapsed() async {
|
||||||
|
let harness = makeHarness(fetchCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
one.example,1,1
|
||||||
|
""")
|
||||||
|
harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt")
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||||
|
XCTAssertEqual(requestCount, 0)
|
||||||
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_prefetchIfNeeded_successUpdatesEntriesPersistsCacheAndSkipsImmediateForcedRefetch() async {
|
||||||
|
let csv = """
|
||||||
|
relay url,lat,lon
|
||||||
|
refreshed.example,12,34
|
||||||
|
"""
|
||||||
|
let harness = makeHarness(fetchCSV: csv)
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded()
|
||||||
|
let refreshed = await waitUntil {
|
||||||
|
directory.entries == [GeoRelayDirectory.Entry(host: "refreshed.example", lat: 12, lon: 34)]
|
||||||
|
}
|
||||||
|
XCTAssertTrue(refreshed)
|
||||||
|
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||||
|
XCTAssertEqual(requestCount, 1)
|
||||||
|
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], csv.data(using: .utf8))
|
||||||
|
XCTAssertEqual(harness.userDefaults.object(forKey: "georelay.lastFetchAt") as? Date, harness.clock.now)
|
||||||
|
XCTAssertEqual(directory.debugRetryAttempt, 0)
|
||||||
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded(force: true)
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
let forcedRequestCount = await harness.fetcher.recordedRequestCount()
|
||||||
|
XCTAssertEqual(forcedRequestCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {
|
||||||
|
var factoryThreadFlags: [Bool] = []
|
||||||
|
let threadRecorder = MainThreadRecorder()
|
||||||
|
let harness = makeHarness(
|
||||||
|
fetchCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
background.example,8,9
|
||||||
|
""",
|
||||||
|
fetchFactoryObserver: {
|
||||||
|
factoryThreadFlags.append(isExecutingOnMainThread())
|
||||||
|
},
|
||||||
|
fetchObserver: {
|
||||||
|
await threadRecorder.record(isExecutingOnMainThread())
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded()
|
||||||
|
|
||||||
|
let refreshed = await waitUntil {
|
||||||
|
directory.entries == [GeoRelayDirectory.Entry(host: "background.example", lat: 8, lon: 9)]
|
||||||
|
}
|
||||||
|
XCTAssertTrue(refreshed)
|
||||||
|
XCTAssertEqual(factoryThreadFlags, [true])
|
||||||
|
let recordedValues = await threadRecorder.recordedValues()
|
||||||
|
XCTAssertEqual(recordedValues, [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_prefetchIfNeeded_failureSchedulesRetryAndRecoversOnNextFetch() async {
|
||||||
|
let csv = """
|
||||||
|
relay url,lat,lon
|
||||||
|
retry.example,5,6
|
||||||
|
"""
|
||||||
|
let harness = makeHarness(
|
||||||
|
fetchResults: [
|
||||||
|
.failure(GeoRelayTestError.network),
|
||||||
|
.success(csv.data(using: .utf8)!)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded()
|
||||||
|
|
||||||
|
let recovered = await waitUntil {
|
||||||
|
directory.entries == [GeoRelayDirectory.Entry(host: "retry.example", lat: 5, lon: 6)]
|
||||||
|
}
|
||||||
|
XCTAssertTrue(recovered)
|
||||||
|
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||||
|
let retryDelays = await harness.retryRecorder.recordedDelays()
|
||||||
|
XCTAssertEqual(requestCount, 2)
|
||||||
|
XCTAssertEqual(retryDelays, [5])
|
||||||
|
XCTAssertEqual(directory.debugRetryAttempt, 0)
|
||||||
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||||
|
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||||
|
let harness = makeHarness(
|
||||||
|
fetchCSV: """
|
||||||
|
relay url,lat,lon
|
||||||
|
observer.example,1,2
|
||||||
|
""",
|
||||||
|
autoStart: true,
|
||||||
|
activeNotificationName: activeNotification
|
||||||
|
)
|
||||||
|
var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
let initialFetch = await waitUntil {
|
||||||
|
await harness.fetcher.recordedRequestCount() == 1
|
||||||
|
}
|
||||||
|
XCTAssertTrue(initialFetch)
|
||||||
|
XCTAssertEqual(directory?.debugObserverCount, 2)
|
||||||
|
|
||||||
|
harness.clock.now = harness.clock.now.addingTimeInterval(6)
|
||||||
|
harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)
|
||||||
|
let torTriggered = await waitUntil {
|
||||||
|
await harness.fetcher.recordedRequestCount() == 2
|
||||||
|
}
|
||||||
|
XCTAssertTrue(torTriggered)
|
||||||
|
|
||||||
|
harness.clock.now = harness.clock.now.addingTimeInterval(61)
|
||||||
|
harness.notificationCenter.post(name: activeNotification, object: nil)
|
||||||
|
let activeTriggered = await waitUntil {
|
||||||
|
await harness.fetcher.recordedRequestCount() == 3
|
||||||
|
}
|
||||||
|
XCTAssertTrue(activeTriggered)
|
||||||
|
|
||||||
|
weak var weakDirectory: GeoRelayDirectory?
|
||||||
|
weakDirectory = directory
|
||||||
|
directory = nil
|
||||||
|
XCTAssertNil(weakDirectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHarness(
|
||||||
|
cacheCSV: String? = nil,
|
||||||
|
bundleCSV: String? = nil,
|
||||||
|
workingDirectoryCSV: String? = nil,
|
||||||
|
fetchCSV: String? = nil,
|
||||||
|
fetchResults: [Result<Data, Error>] = [],
|
||||||
|
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||||
|
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||||
|
autoStart: Bool = false,
|
||||||
|
activeNotificationName: Notification.Name? = nil
|
||||||
|
) -> GeoRelayHarness {
|
||||||
|
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||||
|
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||||
|
userDefaults.removePersistentDomain(forName: userDefaultsSuite)
|
||||||
|
|
||||||
|
let notificationCenter = NotificationCenter()
|
||||||
|
let clock = MutableGeoClock(now: Date(timeIntervalSince1970: 1_700_000_000))
|
||||||
|
let fileStore = InMemoryFileStore()
|
||||||
|
let cacheURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-cache.csv")
|
||||||
|
let bundleURL = URL(fileURLWithPath: "/tmp/\(UUID().uuidString)-bundle.csv")
|
||||||
|
let cwd = "/tmp/\(UUID().uuidString)-cwd"
|
||||||
|
let cwdURL = URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")
|
||||||
|
|
||||||
|
if let cacheCSV {
|
||||||
|
fileStore.dataByURL[cacheURL] = Data(cacheCSV.utf8)
|
||||||
|
}
|
||||||
|
if let bundleCSV {
|
||||||
|
fileStore.dataByURL[bundleURL] = Data(bundleCSV.utf8)
|
||||||
|
}
|
||||||
|
if let workingDirectoryCSV {
|
||||||
|
fileStore.dataByURL[cwdURL] = Data(workingDirectoryCSV.utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultFetchData = Data((fetchCSV ?? bundleCSV ?? cacheCSV ?? "relay url,lat,lon\nfallback.example,0,0\n").utf8)
|
||||||
|
let fetcher = FetchProbe(responses: fetchResults, defaultData: defaultFetchData)
|
||||||
|
let retryRecorder = RetryDelayRecorder()
|
||||||
|
|
||||||
|
let dependencies = GeoRelayDirectoryDependencies(
|
||||||
|
userDefaults: userDefaults,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
now: { clock.now },
|
||||||
|
remoteURL: URL(string: "https://example.com/nostr_relays.csv")!,
|
||||||
|
fetchInterval: 60,
|
||||||
|
refreshCheckInterval: 0,
|
||||||
|
retryInitialSeconds: 5,
|
||||||
|
retryMaxSeconds: 40,
|
||||||
|
awaitTorReady: { true },
|
||||||
|
makeFetchData: {
|
||||||
|
fetchFactoryObserver?()
|
||||||
|
return { request in
|
||||||
|
await fetchObserver?()
|
||||||
|
return try await fetcher.fetch(request)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
readData: { url in
|
||||||
|
fileStore.dataByURL[url]
|
||||||
|
},
|
||||||
|
writeData: { data, url in
|
||||||
|
fileStore.dataByURL[url] = data
|
||||||
|
},
|
||||||
|
cacheURL: { cacheURL },
|
||||||
|
bundledCSVURLs: bundleCSV == nil ? { [] } : { [bundleURL] },
|
||||||
|
currentDirectoryPath: workingDirectoryCSV == nil ? { nil } : { cwd },
|
||||||
|
retrySleep: { delay in
|
||||||
|
await retryRecorder.record(delay)
|
||||||
|
},
|
||||||
|
torReadyNotificationName: .TorDidBecomeReady,
|
||||||
|
activeNotificationName: activeNotificationName,
|
||||||
|
autoStart: autoStart
|
||||||
|
)
|
||||||
|
|
||||||
|
return GeoRelayHarness(
|
||||||
|
dependencies: dependencies,
|
||||||
|
clock: clock,
|
||||||
|
fileStore: fileStore,
|
||||||
|
fetcher: fetcher,
|
||||||
|
retryRecorder: retryRecorder,
|
||||||
|
userDefaults: userDefaults,
|
||||||
|
notificationCenter: notificationCenter,
|
||||||
|
cacheURL: cacheURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitUntil(
|
||||||
|
timeout: TimeInterval = 1.0,
|
||||||
|
condition: @escaping @MainActor () async -> Bool
|
||||||
|
) async -> Bool {
|
||||||
|
let deadline = Date().addingTimeInterval(timeout)
|
||||||
|
while Date() < deadline {
|
||||||
|
if await condition() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
try? await Task.sleep(nanoseconds: 10_000_000)
|
||||||
|
}
|
||||||
|
return await condition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct GeoRelayHarness {
|
||||||
|
let dependencies: GeoRelayDirectoryDependencies
|
||||||
|
let clock: MutableGeoClock
|
||||||
|
let fileStore: InMemoryFileStore
|
||||||
|
let fetcher: FetchProbe
|
||||||
|
let retryRecorder: RetryDelayRecorder
|
||||||
|
let userDefaults: UserDefaults
|
||||||
|
let notificationCenter: NotificationCenter
|
||||||
|
let cacheURL: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MutableGeoClock {
|
||||||
|
var now: Date
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
self.now = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class InMemoryFileStore {
|
||||||
|
var dataByURL: [URL: Data] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor FetchProbe {
|
||||||
|
private var responses: [Result<Data, Error>]
|
||||||
|
private let defaultData: Data
|
||||||
|
private(set) var requestCount = 0
|
||||||
|
|
||||||
|
init(responses: [Result<Data, Error>], defaultData: Data) {
|
||||||
|
self.responses = responses
|
||||||
|
self.defaultData = defaultData
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(_ request: URLRequest) async throws -> Data {
|
||||||
|
_ = request
|
||||||
|
requestCount += 1
|
||||||
|
if !responses.isEmpty {
|
||||||
|
return try responses.removeFirst().get()
|
||||||
|
}
|
||||||
|
return defaultData
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedRequestCount() -> Int {
|
||||||
|
requestCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor RetryDelayRecorder {
|
||||||
|
private(set) var delays: [TimeInterval] = []
|
||||||
|
|
||||||
|
func record(_ delay: TimeInterval) {
|
||||||
|
delays.append(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedDelays() -> [TimeInterval] {
|
||||||
|
delays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private actor MainThreadRecorder {
|
||||||
|
private var values: [Bool] = []
|
||||||
|
|
||||||
|
func record(_ value: Bool) {
|
||||||
|
values.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordedValues() -> [Bool] {
|
||||||
|
values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum GeoRelayTestError: Error {
|
||||||
|
case network
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExecutingOnMainThread() -> Bool {
|
||||||
|
Thread.isMainThread
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@
|
|||||||
// Tests for NIP-17 gift-wrapped private messages
|
// Tests for NIP-17 gift-wrapped private messages
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import Nostr
|
||||||
import Testing
|
import Testing
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
struct NostrProtocolTests {
|
struct NostrProtocolTests {
|
||||||
@@ -213,6 +215,45 @@ struct NostrProtocolTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func nostrEventSignatureVerification_roundTrip() throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: identity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: [],
|
||||||
|
content: "Signed event"
|
||||||
|
)
|
||||||
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||||
|
#expect(signed.isValidSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func nostrEventSignatureVerification_detectsTamper() throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let event = NostrEvent(
|
||||||
|
pubkey: identity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: [],
|
||||||
|
content: "Original"
|
||||||
|
)
|
||||||
|
var signed = try event.sign(with: identity.schnorrSigningKey())
|
||||||
|
signed.id = "deadbeef"
|
||||||
|
#expect(!signed.isValidSignature())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func geohashNotesSingleFilter_encodesExpectedTagShape() throws {
|
||||||
|
let since = Date(timeIntervalSince1970: 1_234_567)
|
||||||
|
let filter = NostrFilter.geohashNotes("u4pruyd", since: since, limit: 42)
|
||||||
|
let data = try JSONEncoder().encode(filter)
|
||||||
|
let object = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
|
||||||
|
#expect(object["kinds"] as? [Int] == [1])
|
||||||
|
#expect(object["#g"] as? [String] == ["u4pruyd"])
|
||||||
|
#expect(object["since"] as? Int == 1_234_567)
|
||||||
|
#expect(object["limit"] as? Int == 42)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
private static func base64URLDecode(_ s: String) -> Data? {
|
private static func base64URLDecode(_ s: String) -> Data? {
|
||||||
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
@Suite("PreviewKeychainManager Tests")
|
||||||
|
struct PreviewKeychainManagerTests {
|
||||||
|
|
||||||
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
|
func previewKeychainManagerRoundTripsData() {
|
||||||
|
let manager = PreviewKeychainManager()
|
||||||
|
let identityKey = Data([1, 2, 3, 4])
|
||||||
|
let serviceKey = "preview-service"
|
||||||
|
let scopedData = Data([9, 8, 7, 6])
|
||||||
|
|
||||||
|
#expect(!manager.verifyIdentityKeyExists())
|
||||||
|
#expect(manager.saveIdentityKey(identityKey, forKey: "noiseStaticKey"))
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == identityKey)
|
||||||
|
#expect(manager.saveIdentityKey(identityKey, forKey: "identity_noiseStaticKey"))
|
||||||
|
#expect(manager.verifyIdentityKeyExists())
|
||||||
|
|
||||||
|
if case .success(let stored) = manager.getIdentityKeyWithResult(forKey: "noiseStaticKey") {
|
||||||
|
#expect(stored == identityKey)
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected stored preview identity key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .success = manager.saveIdentityKeyWithResult(Data([5, 6, 7]), forKey: "ed25519SigningKey") {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected preview keychain save to succeed")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.save(key: "blob", data: scopedData, service: serviceKey, accessible: nil)
|
||||||
|
#expect(manager.load(key: "blob", service: serviceKey) == scopedData)
|
||||||
|
manager.delete(key: "blob", service: serviceKey)
|
||||||
|
#expect(manager.load(key: "blob", service: serviceKey) == nil)
|
||||||
|
|
||||||
|
var secretData = Data([4, 3, 2, 1])
|
||||||
|
var secretString = "secret"
|
||||||
|
manager.secureClear(&secretData)
|
||||||
|
manager.secureClear(&secretString)
|
||||||
|
#expect(secretData == Data([4, 3, 2, 1]))
|
||||||
|
#expect(secretString == "secret")
|
||||||
|
|
||||||
|
#expect(manager.deleteIdentityKey(forKey: "noiseStaticKey"))
|
||||||
|
#expect(manager.deleteIdentityKey(forKey: "identity_noiseStaticKey"))
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||||
|
#expect(manager.deleteAllKeychainData())
|
||||||
|
|
||||||
|
if case .itemNotFound = manager.getIdentityKeyWithResult(forKey: "ed25519SigningKey") {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user